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/components/boards/Board.tsx | 9 + src/components/charts/BarChart.tsx | 131 ++++++ src/components/charts/BubbleChart.tsx | 31 ++ src/components/charts/Chart.tsx | 130 ++++++ src/components/charts/ChartTooltip.tsx | 23 + src/components/charts/PieChart.tsx | 31 ++ src/components/common/ActionForm.tsx | 15 + src/components/common/AnimatedDiv.tsx | 3 + src/components/common/Avatar.tsx | 21 + src/components/common/ConfirmationForm.tsx | 42 ++ src/components/common/DataGrid.tsx | 107 +++++ src/components/common/DateDisplay.tsx | 28 ++ src/components/common/DateDistance.tsx | 19 + src/components/common/Empty.tsx | 24 + src/components/common/EmptyPlaceholder.tsx | 28 ++ src/components/common/ErrorBoundary.tsx | 38 ++ src/components/common/ErrorMessage.tsx | 16 + src/components/common/ExternalLink.tsx | 23 + src/components/common/Favicon.tsx | 22 + src/components/common/FilterLink.tsx | 49 ++ src/components/common/FilterRecord.tsx | 117 +++++ src/components/common/GridRow.tsx | 32 ++ src/components/common/LinkButton.tsx | 41 ++ src/components/common/LoadingPanel.tsx | 71 +++ src/components/common/PageBody.tsx | 42 ++ src/components/common/PageHeader.tsx | 58 +++ src/components/common/Pager.tsx | 60 +++ src/components/common/Panel.tsx | 64 +++ src/components/common/SectionHeader.tsx | 28 ++ src/components/common/SideMenu.tsx | 80 ++++ src/components/common/TypeConfirmationForm.tsx | 55 +++ src/components/common/TypeIcon.tsx | 29 ++ src/components/hooks/context/useLink.ts | 6 + src/components/hooks/context/usePixel.ts | 6 + src/components/hooks/context/useTeam.ts | 6 + src/components/hooks/context/useUser.ts | 6 + src/components/hooks/context/useWebsite.ts | 6 + src/components/hooks/index.ts | 84 ++++ .../hooks/queries/useActiveUsersQuery.ts | 12 + src/components/hooks/queries/useDateRangeQuery.ts | 23 + src/components/hooks/queries/useDeleteQuery.ts | 12 + .../hooks/queries/useEventDataEventsQuery.ts | 27 ++ .../hooks/queries/useEventDataPropertiesQuery.ts | 27 ++ src/components/hooks/queries/useEventDataQuery.ts | 27 ++ .../hooks/queries/useEventDataValuesQuery.ts | 34 ++ src/components/hooks/queries/useLinkQuery.ts | 15 + src/components/hooks/queries/useLinksQuery.ts | 17 + src/components/hooks/queries/useLoginQuery.ts | 23 + src/components/hooks/queries/usePixelQuery.ts | 15 + src/components/hooks/queries/usePixelsQuery.ts | 17 + src/components/hooks/queries/useRealtimeQuery.ts | 17 + src/components/hooks/queries/useReportQuery.ts | 15 + src/components/hooks/queries/useReportsQuery.ts | 19 + src/components/hooks/queries/useResultQuery.ts | 44 ++ .../hooks/queries/useSessionActivityQuery.ts | 21 + .../hooks/queries/useSessionDataPropertiesQuery.ts | 27 ++ .../hooks/queries/useSessionDataQuery.ts | 12 + .../hooks/queries/useSessionDataValuesQuery.ts | 32 ++ src/components/hooks/queries/useShareTokenQuery.ts | 25 + .../hooks/queries/useTeamMembersQuery.ts | 16 + src/components/hooks/queries/useTeamQuery.ts | 17 + .../hooks/queries/useTeamWebsitesQuery.ts | 15 + src/components/hooks/queries/useTeamsQuery.ts | 20 + src/components/hooks/queries/useUpdateQuery.ts | 15 + src/components/hooks/queries/useUserQuery.ts | 17 + src/components/hooks/queries/useUserTeamsQuery.ts | 15 + .../hooks/queries/useUserWebsitesQuery.ts | 31 ++ src/components/hooks/queries/useUsersQuery.ts | 17 + .../hooks/queries/useWebsiteCohortQuery.ts | 21 + .../hooks/queries/useWebsiteCohortsQuery.ts | 25 + .../hooks/queries/useWebsiteEventsQuery.ts | 39 ++ .../hooks/queries/useWebsiteEventsSeriesQuery.ts | 18 + .../queries/useWebsiteExpandedMetricsQuery.ts | 51 ++ .../hooks/queries/useWebsiteMetricsQuery.ts | 47 ++ .../hooks/queries/useWebsitePageviewsQuery.ts | 36 ++ src/components/hooks/queries/useWebsiteQuery.ts | 17 + .../hooks/queries/useWebsiteSegmentQuery.ts | 21 + .../hooks/queries/useWebsiteSegmentsQuery.ts | 24 + .../hooks/queries/useWebsiteSessionQuery.ts | 13 + .../hooks/queries/useWebsiteSessionStatsQuery.ts | 17 + .../hooks/queries/useWebsiteSessionsQuery.ts | 34 ++ .../hooks/queries/useWebsiteStatsQuery.ts | 36 ++ .../hooks/queries/useWebsiteValuesQuery.ts | 62 +++ src/components/hooks/queries/useWebsitesQuery.ts | 20 + .../hooks/queries/useWeeklyTrafficQuery.ts | 28 ++ src/components/hooks/useApi.ts | 67 +++ src/components/hooks/useConfig.ts | 33 ++ src/components/hooks/useCountryNames.ts | 32 ++ src/components/hooks/useDateParameters.ts | 18 + src/components/hooks/useDateRange.ts | 37 ++ src/components/hooks/useDocumentClick.ts | 13 + src/components/hooks/useEscapeKey.ts | 19 + src/components/hooks/useFields.ts | 23 + src/components/hooks/useFilterParameters.ts | 70 +++ src/components/hooks/useFilters.ts | 99 ++++ src/components/hooks/useForceUpdate.ts | 9 + src/components/hooks/useFormat.ts | 74 +++ src/components/hooks/useGlobalState.ts | 13 + src/components/hooks/useLanguageNames.ts | 32 ++ src/components/hooks/useLocale.ts | 60 +++ src/components/hooks/useMessages.ts | 48 ++ src/components/hooks/useMobile.ts | 9 + src/components/hooks/useModified.ts | 13 + src/components/hooks/useNavigation.ts | 43 ++ src/components/hooks/usePageParameters.ts | 16 + src/components/hooks/usePagedQuery.ts | 27 ++ src/components/hooks/useRegionNames.ts | 22 + src/components/hooks/useSlug.ts | 14 + src/components/hooks/useSticky.ts | 25 + src/components/hooks/useTimezone.ts | 95 ++++ src/components/icons.ts | 1 + src/components/input/ActionSelect.tsx | 18 + src/components/input/CurrencySelect.tsx | 34 ++ src/components/input/DateFilter.tsx | 141 ++++++ src/components/input/DialogButton.tsx | 64 +++ src/components/input/DownloadButton.tsx | 42 ++ src/components/input/ExportButton.tsx | 64 +++ src/components/input/FieldFilters.tsx | 117 +++++ src/components/input/FilterBar.tsx | 155 ++++++ src/components/input/FilterButtons.tsx | 33 ++ src/components/input/FilterEditForm.tsx | 95 ++++ src/components/input/LanguageButton.tsx | 41 ++ src/components/input/LookupField.tsx | 65 +++ src/components/input/MenuButton.tsx | 32 ++ src/components/input/MobileMenuButton.tsx | 17 + src/components/input/MonthFilter.tsx | 18 + src/components/input/MonthSelect.tsx | 47 ++ src/components/input/NavButton.tsx | 188 ++++++++ src/components/input/PanelButton.tsx | 19 + src/components/input/PreferencesButton.tsx | 32 ++ src/components/input/ProfileButton.tsx | 74 +++ src/components/input/RefreshButton.tsx | 32 ++ src/components/input/ReportEditButton.tsx | 99 ++++ src/components/input/SegmentFilters.tsx | 42 ++ src/components/input/SegmentSaveButton.tsx | 26 ++ src/components/input/SettingsButton.tsx | 84 ++++ src/components/input/WebsiteDateFilter.tsx | 102 ++++ src/components/input/WebsiteFilterButton.tsx | 32 ++ src/components/input/WebsiteSelect.tsx | 74 +++ src/components/messages.ts | 518 +++++++++++++++++++++ src/components/metrics/ActiveUsers.tsx | 39 ++ src/components/metrics/ChangeLabel.tsx | 60 +++ src/components/metrics/DatePickerForm.tsx | 74 +++ src/components/metrics/EventData.tsx | 22 + src/components/metrics/EventsChart.tsx | 93 ++++ src/components/metrics/Legend.tsx | 39 ++ src/components/metrics/ListTable.tsx | 152 ++++++ src/components/metrics/MetricCard.tsx | 56 +++ src/components/metrics/MetricLabel.tsx | 142 ++++++ src/components/metrics/MetricsBar.tsx | 14 + src/components/metrics/MetricsExpandedTable.tsx | 139 ++++++ src/components/metrics/MetricsTable.tsx | 95 ++++ src/components/metrics/PageviewsChart.tsx | 98 ++++ src/components/metrics/RealtimeChart.tsx | 59 +++ src/components/metrics/WeeklyTraffic.tsx | 112 +++++ src/components/metrics/WorldMap.tsx | 105 +++++ src/components/svg/AddUser.tsx | 16 + src/components/svg/BarChart.tsx | 8 + src/components/svg/Bars.tsx | 8 + src/components/svg/Bolt.tsx | 8 + src/components/svg/Bookmark.tsx | 8 + src/components/svg/Calendar.tsx | 8 + src/components/svg/Change.tsx | 13 + src/components/svg/Clock.tsx | 12 + src/components/svg/Compare.tsx | 8 + src/components/svg/Dashboard.tsx | 21 + src/components/svg/Download.tsx | 9 + src/components/svg/Expand.tsx | 18 + src/components/svg/Export.tsx | 12 + src/components/svg/Flag.tsx | 8 + src/components/svg/Funnel.tsx | 18 + src/components/svg/Gear.tsx | 8 + src/components/svg/Globe.tsx | 8 + src/components/svg/Lightbulb.tsx | 15 + src/components/svg/Lightning.tsx | 33 ++ src/components/svg/Link.tsx | 8 + src/components/svg/Location.tsx | 8 + src/components/svg/Lock.tsx | 8 + src/components/svg/Logo.tsx | 17 + src/components/svg/LogoWhite.tsx | 26 ++ src/components/svg/Magnet.tsx | 15 + src/components/svg/Money.tsx | 15 + src/components/svg/Moon.tsx | 8 + src/components/svg/Network.tsx | 15 + src/components/svg/Nodes.tsx | 12 + src/components/svg/Overview.tsx | 8 + src/components/svg/Path.tsx | 15 + src/components/svg/Profile.tsx | 8 + src/components/svg/Pushpin.tsx | 8 + src/components/svg/Redo.tsx | 8 + src/components/svg/Reports.tsx | 8 + src/components/svg/Security.tsx | 16 + src/components/svg/Speaker.tsx | 8 + src/components/svg/Sun.tsx | 9 + src/components/svg/Switch.tsx | 19 + src/components/svg/Tag.tsx | 16 + src/components/svg/Target.tsx | 21 + src/components/svg/Visitor.tsx | 8 + src/components/svg/Website.tsx | 13 + src/components/svg/index.ts | 37 ++ 200 files changed, 7803 insertions(+) create mode 100644 src/components/boards/Board.tsx create mode 100644 src/components/charts/BarChart.tsx create mode 100644 src/components/charts/BubbleChart.tsx create mode 100644 src/components/charts/Chart.tsx create mode 100644 src/components/charts/ChartTooltip.tsx create mode 100644 src/components/charts/PieChart.tsx create mode 100644 src/components/common/ActionForm.tsx create mode 100644 src/components/common/AnimatedDiv.tsx create mode 100644 src/components/common/Avatar.tsx create mode 100644 src/components/common/ConfirmationForm.tsx create mode 100644 src/components/common/DataGrid.tsx create mode 100644 src/components/common/DateDisplay.tsx create mode 100644 src/components/common/DateDistance.tsx create mode 100644 src/components/common/Empty.tsx create mode 100644 src/components/common/EmptyPlaceholder.tsx create mode 100644 src/components/common/ErrorBoundary.tsx create mode 100644 src/components/common/ErrorMessage.tsx create mode 100644 src/components/common/ExternalLink.tsx create mode 100644 src/components/common/Favicon.tsx create mode 100644 src/components/common/FilterLink.tsx create mode 100644 src/components/common/FilterRecord.tsx create mode 100644 src/components/common/GridRow.tsx create mode 100644 src/components/common/LinkButton.tsx create mode 100644 src/components/common/LoadingPanel.tsx create mode 100644 src/components/common/PageBody.tsx create mode 100644 src/components/common/PageHeader.tsx create mode 100644 src/components/common/Pager.tsx create mode 100644 src/components/common/Panel.tsx create mode 100644 src/components/common/SectionHeader.tsx create mode 100644 src/components/common/SideMenu.tsx create mode 100644 src/components/common/TypeConfirmationForm.tsx create mode 100644 src/components/common/TypeIcon.tsx create mode 100644 src/components/hooks/context/useLink.ts create mode 100644 src/components/hooks/context/usePixel.ts create mode 100644 src/components/hooks/context/useTeam.ts create mode 100644 src/components/hooks/context/useUser.ts create mode 100644 src/components/hooks/context/useWebsite.ts create mode 100644 src/components/hooks/index.ts create mode 100644 src/components/hooks/queries/useActiveUsersQuery.ts create mode 100644 src/components/hooks/queries/useDateRangeQuery.ts create mode 100644 src/components/hooks/queries/useDeleteQuery.ts create mode 100644 src/components/hooks/queries/useEventDataEventsQuery.ts create mode 100644 src/components/hooks/queries/useEventDataPropertiesQuery.ts create mode 100644 src/components/hooks/queries/useEventDataQuery.ts create mode 100644 src/components/hooks/queries/useEventDataValuesQuery.ts create mode 100644 src/components/hooks/queries/useLinkQuery.ts create mode 100644 src/components/hooks/queries/useLinksQuery.ts create mode 100644 src/components/hooks/queries/useLoginQuery.ts create mode 100644 src/components/hooks/queries/usePixelQuery.ts create mode 100644 src/components/hooks/queries/usePixelsQuery.ts create mode 100644 src/components/hooks/queries/useRealtimeQuery.ts create mode 100644 src/components/hooks/queries/useReportQuery.ts create mode 100644 src/components/hooks/queries/useReportsQuery.ts create mode 100644 src/components/hooks/queries/useResultQuery.ts create mode 100644 src/components/hooks/queries/useSessionActivityQuery.ts create mode 100644 src/components/hooks/queries/useSessionDataPropertiesQuery.ts create mode 100644 src/components/hooks/queries/useSessionDataQuery.ts create mode 100644 src/components/hooks/queries/useSessionDataValuesQuery.ts create mode 100644 src/components/hooks/queries/useShareTokenQuery.ts create mode 100644 src/components/hooks/queries/useTeamMembersQuery.ts create mode 100644 src/components/hooks/queries/useTeamQuery.ts create mode 100644 src/components/hooks/queries/useTeamWebsitesQuery.ts create mode 100644 src/components/hooks/queries/useTeamsQuery.ts create mode 100644 src/components/hooks/queries/useUpdateQuery.ts create mode 100644 src/components/hooks/queries/useUserQuery.ts create mode 100644 src/components/hooks/queries/useUserTeamsQuery.ts create mode 100644 src/components/hooks/queries/useUserWebsitesQuery.ts create mode 100644 src/components/hooks/queries/useUsersQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteCohortQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteCohortsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteEventsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteMetricsQuery.ts create mode 100644 src/components/hooks/queries/useWebsitePageviewsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteSegmentQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteSegmentsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteSessionQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteSessionStatsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteSessionsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteStatsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteValuesQuery.ts create mode 100644 src/components/hooks/queries/useWebsitesQuery.ts create mode 100644 src/components/hooks/queries/useWeeklyTrafficQuery.ts create mode 100644 src/components/hooks/useApi.ts create mode 100644 src/components/hooks/useConfig.ts create mode 100644 src/components/hooks/useCountryNames.ts create mode 100644 src/components/hooks/useDateParameters.ts create mode 100644 src/components/hooks/useDateRange.ts create mode 100644 src/components/hooks/useDocumentClick.ts create mode 100644 src/components/hooks/useEscapeKey.ts create mode 100644 src/components/hooks/useFields.ts create mode 100644 src/components/hooks/useFilterParameters.ts create mode 100644 src/components/hooks/useFilters.ts create mode 100644 src/components/hooks/useForceUpdate.ts create mode 100644 src/components/hooks/useFormat.ts create mode 100644 src/components/hooks/useGlobalState.ts create mode 100644 src/components/hooks/useLanguageNames.ts create mode 100644 src/components/hooks/useLocale.ts create mode 100644 src/components/hooks/useMessages.ts create mode 100644 src/components/hooks/useMobile.ts create mode 100644 src/components/hooks/useModified.ts create mode 100644 src/components/hooks/useNavigation.ts create mode 100644 src/components/hooks/usePageParameters.ts create mode 100644 src/components/hooks/usePagedQuery.ts create mode 100644 src/components/hooks/useRegionNames.ts create mode 100644 src/components/hooks/useSlug.ts create mode 100644 src/components/hooks/useSticky.ts create mode 100644 src/components/hooks/useTimezone.ts create mode 100644 src/components/icons.ts create mode 100644 src/components/input/ActionSelect.tsx create mode 100644 src/components/input/CurrencySelect.tsx create mode 100644 src/components/input/DateFilter.tsx create mode 100644 src/components/input/DialogButton.tsx create mode 100644 src/components/input/DownloadButton.tsx create mode 100644 src/components/input/ExportButton.tsx create mode 100644 src/components/input/FieldFilters.tsx create mode 100644 src/components/input/FilterBar.tsx create mode 100644 src/components/input/FilterButtons.tsx create mode 100644 src/components/input/FilterEditForm.tsx create mode 100644 src/components/input/LanguageButton.tsx create mode 100644 src/components/input/LookupField.tsx create mode 100644 src/components/input/MenuButton.tsx create mode 100644 src/components/input/MobileMenuButton.tsx create mode 100644 src/components/input/MonthFilter.tsx create mode 100644 src/components/input/MonthSelect.tsx create mode 100644 src/components/input/NavButton.tsx create mode 100644 src/components/input/PanelButton.tsx create mode 100644 src/components/input/PreferencesButton.tsx create mode 100644 src/components/input/ProfileButton.tsx create mode 100644 src/components/input/RefreshButton.tsx create mode 100644 src/components/input/ReportEditButton.tsx create mode 100644 src/components/input/SegmentFilters.tsx create mode 100644 src/components/input/SegmentSaveButton.tsx create mode 100644 src/components/input/SettingsButton.tsx create mode 100644 src/components/input/WebsiteDateFilter.tsx create mode 100644 src/components/input/WebsiteFilterButton.tsx create mode 100644 src/components/input/WebsiteSelect.tsx create mode 100644 src/components/messages.ts create mode 100644 src/components/metrics/ActiveUsers.tsx create mode 100644 src/components/metrics/ChangeLabel.tsx create mode 100644 src/components/metrics/DatePickerForm.tsx create mode 100644 src/components/metrics/EventData.tsx create mode 100644 src/components/metrics/EventsChart.tsx create mode 100644 src/components/metrics/Legend.tsx create mode 100644 src/components/metrics/ListTable.tsx create mode 100644 src/components/metrics/MetricCard.tsx create mode 100644 src/components/metrics/MetricLabel.tsx create mode 100644 src/components/metrics/MetricsBar.tsx create mode 100644 src/components/metrics/MetricsExpandedTable.tsx create mode 100644 src/components/metrics/MetricsTable.tsx create mode 100644 src/components/metrics/PageviewsChart.tsx create mode 100644 src/components/metrics/RealtimeChart.tsx create mode 100644 src/components/metrics/WeeklyTraffic.tsx create mode 100644 src/components/metrics/WorldMap.tsx create mode 100644 src/components/svg/AddUser.tsx create mode 100644 src/components/svg/BarChart.tsx create mode 100644 src/components/svg/Bars.tsx create mode 100644 src/components/svg/Bolt.tsx create mode 100644 src/components/svg/Bookmark.tsx create mode 100644 src/components/svg/Calendar.tsx create mode 100644 src/components/svg/Change.tsx create mode 100644 src/components/svg/Clock.tsx create mode 100644 src/components/svg/Compare.tsx create mode 100644 src/components/svg/Dashboard.tsx create mode 100644 src/components/svg/Download.tsx create mode 100644 src/components/svg/Expand.tsx create mode 100644 src/components/svg/Export.tsx create mode 100644 src/components/svg/Flag.tsx create mode 100644 src/components/svg/Funnel.tsx create mode 100644 src/components/svg/Gear.tsx create mode 100644 src/components/svg/Globe.tsx create mode 100644 src/components/svg/Lightbulb.tsx create mode 100644 src/components/svg/Lightning.tsx create mode 100644 src/components/svg/Link.tsx create mode 100644 src/components/svg/Location.tsx create mode 100644 src/components/svg/Lock.tsx create mode 100644 src/components/svg/Logo.tsx create mode 100644 src/components/svg/LogoWhite.tsx create mode 100644 src/components/svg/Magnet.tsx create mode 100644 src/components/svg/Money.tsx create mode 100644 src/components/svg/Moon.tsx create mode 100644 src/components/svg/Network.tsx create mode 100644 src/components/svg/Nodes.tsx create mode 100644 src/components/svg/Overview.tsx create mode 100644 src/components/svg/Path.tsx create mode 100644 src/components/svg/Profile.tsx create mode 100644 src/components/svg/Pushpin.tsx create mode 100644 src/components/svg/Redo.tsx create mode 100644 src/components/svg/Reports.tsx create mode 100644 src/components/svg/Security.tsx create mode 100644 src/components/svg/Speaker.tsx create mode 100644 src/components/svg/Sun.tsx create mode 100644 src/components/svg/Switch.tsx create mode 100644 src/components/svg/Tag.tsx create mode 100644 src/components/svg/Target.tsx create mode 100644 src/components/svg/Visitor.tsx create mode 100644 src/components/svg/Website.tsx create mode 100644 src/components/svg/index.ts (limited to 'src/components') diff --git a/src/components/boards/Board.tsx b/src/components/boards/Board.tsx new file mode 100644 index 0000000..70f0fa0 --- /dev/null +++ b/src/components/boards/Board.tsx @@ -0,0 +1,9 @@ +import { Column } from '@umami/react-zen'; + +export interface BoardProps { + children?: React.ReactNode; +} + +export function Board({ children }: BoardProps) { + return {children}; +} diff --git a/src/components/charts/BarChart.tsx b/src/components/charts/BarChart.tsx new file mode 100644 index 0000000..7bfc72d --- /dev/null +++ b/src/components/charts/BarChart.tsx @@ -0,0 +1,131 @@ +import { useTheme } from '@umami/react-zen'; +import { useMemo, useState } from 'react'; +import { Chart, type ChartProps } from '@/components/charts/Chart'; +import { ChartTooltip } from '@/components/charts/ChartTooltip'; +import { useLocale } from '@/components/hooks'; +import { renderNumberLabels } from '@/lib/charts'; +import { getThemeColors } from '@/lib/colors'; +import { DATE_FORMATS, formatDate } from '@/lib/date'; +import { formatLongCurrency, formatLongNumber } from '@/lib/format'; + +const dateFormats = { + millisecond: 'T', + second: 'pp', + minute: 'p', + hour: 'p - PP', + day: 'PPPP', + week: 'PPPP', + month: 'LLLL yyyy', + quarter: 'qqq', + year: 'yyyy', +}; + +export interface BarChartProps extends ChartProps { + unit?: string; + stacked?: boolean; + currency?: string; + renderXLabel?: (label: string, index: number, values: any[]) => string; + renderYLabel?: (label: string, index: number, values: any[]) => string; + XAxisType?: string; + YAxisType?: string; + minDate?: Date; + maxDate?: Date; +} + +export function BarChart({ + chartData, + renderXLabel, + renderYLabel, + unit, + XAxisType = 'timeseries', + YAxisType = 'linear', + stacked = false, + minDate, + maxDate, + currency, + ...props +}: BarChartProps) { + const [tooltip, setTooltip] = useState(null); + const { theme } = useTheme(); + const { locale } = useLocale(); + const { colors } = useMemo(() => getThemeColors(theme), [theme]); + + const chartOptions: any = useMemo(() => { + return { + __id: Date.now(), + scales: { + x: { + type: XAxisType, + stacked: true, + min: formatDate(minDate, DATE_FORMATS[unit], locale), + max: formatDate(maxDate, DATE_FORMATS[unit], locale), + offset: true, + time: { + unit, + }, + grid: { + display: false, + }, + border: { + color: colors.chart.line, + }, + ticks: { + color: colors.chart.text, + autoSkip: false, + maxRotation: 0, + callback: renderXLabel, + }, + }, + y: { + type: YAxisType, + min: 0, + beginAtZero: true, + stacked: !!stacked, + grid: { + color: colors.chart.line, + }, + border: { + color: colors.chart.line, + }, + ticks: { + color: colors.chart.text, + callback: renderYLabel || renderNumberLabels, + }, + }, + }, + }; + }, [chartData, colors, unit, stacked, renderXLabel, renderYLabel]); + + const handleTooltip = ({ tooltip }: { tooltip: any }) => { + const { opacity, labelColors, dataPoints } = tooltip; + + setTooltip( + opacity + ? { + title: formatDate( + new Date(dataPoints[0].raw?.d || dataPoints[0].raw?.x || dataPoints[0].raw), + dateFormats[unit], + locale, + ), + color: labelColors?.[0]?.backgroundColor, + value: currency + ? formatLongCurrency(dataPoints[0].raw.y, currency) + : `${formatLongNumber(dataPoints[0].raw.y)} ${dataPoints[0].dataset.label}`, + } + : null, + ); + }; + + return ( + <> + + {tooltip && } + + ); +} diff --git a/src/components/charts/BubbleChart.tsx b/src/components/charts/BubbleChart.tsx new file mode 100644 index 0000000..bf487ac --- /dev/null +++ b/src/components/charts/BubbleChart.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { Chart, type ChartProps } from '@/components/charts/Chart'; +import { ChartTooltip } from '@/components/charts/ChartTooltip'; + +export interface BubbleChartProps extends ChartProps { + type?: 'bubble'; +} + +export function BubbleChart({ type = 'bubble', ...props }: BubbleChartProps) { + const [tooltip, setTooltip] = useState(null); + + const handleTooltip = ({ tooltip }) => { + const { opacity, labelColors, title, dataPoints } = tooltip; + + setTooltip( + opacity + ? { + color: labelColors?.[0]?.backgroundColor, + value: `${title}: ${dataPoints[0].raw}`, + } + : null, + ); + }; + + return ( + <> + + {tooltip && } + + ); +} diff --git a/src/components/charts/Chart.tsx b/src/components/charts/Chart.tsx new file mode 100644 index 0000000..b6ae9d7 --- /dev/null +++ b/src/components/charts/Chart.tsx @@ -0,0 +1,130 @@ +import { Box, type BoxProps, Column } from '@umami/react-zen'; +import ChartJS, { + type ChartData, + type ChartOptions, + type LegendItem, + type UpdateMode, +} from 'chart.js/auto'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Legend } from '@/components/metrics/Legend'; +import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants'; + +ChartJS.defaults.font.family = 'Inter'; + +export interface ChartProps extends BoxProps { + type?: 'bar' | 'bubble' | 'doughnut' | 'pie' | 'line' | 'polarArea' | 'radar' | 'scatter'; + chartData?: ChartData & { focusLabel?: string }; + chartOptions?: ChartOptions; + updateMode?: UpdateMode; + animationDuration?: number; + onTooltip?: (model: any) => void; +} + +export function Chart({ + type, + chartData, + animationDuration = DEFAULT_ANIMATION_DURATION, + updateMode, + onTooltip, + chartOptions, + ...props +}: ChartProps) { + const canvas = useRef(null); + const chart = useRef(null); + const [legendItems, setLegendItems] = useState([]); + + const options = useMemo(() => { + return { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: animationDuration, + resize: { + duration: 0, + }, + active: { + duration: 0, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + intersect: true, + external: onTooltip, + }, + }, + ...chartOptions, + }; + }, [chartOptions]); + + const handleLegendClick = (item: LegendItem) => { + if (type === 'bar') { + const { datasetIndex } = item; + const meta = chart.current.getDatasetMeta(datasetIndex); + + meta.hidden = + meta.hidden === null ? !chart.current.data.datasets[datasetIndex]?.hidden : null; + } else { + const { index } = item; + const meta = chart.current.getDatasetMeta(0); + const hidden = !!meta?.data?.[index]?.hidden; + + meta.data[index].hidden = !hidden; + chart.current.legend.legendItems[index].hidden = !hidden; + } + + chart.current.update(updateMode); + + setLegendItems(chart.current.legend.legendItems); + }; + + // Create chart + useEffect(() => { + if (canvas.current) { + chart.current = new ChartJS(canvas.current, { + type, + data: chartData, + options, + }); + + setLegendItems(chart.current.legend.legendItems); + } + + return () => { + chart.current?.destroy(); + }; + }, []); + + // Update chart + useEffect(() => { + if (chart.current && chartData) { + // Replace labels and datasets *in-place* + chart.current.data.labels = chartData.labels; + chart.current.data.datasets = chartData.datasets; + + if (chartData.focusLabel !== null) { + chart.current.data.datasets.forEach((ds: { hidden: boolean; label: any }) => { + ds.hidden = chartData.focusLabel ? ds.label !== chartData.focusLabel : false; + }); + } + + chart.current.options = options; + + chart.current.update(updateMode); + + setLegendItems(chart.current.legend.legendItems); + } + }, [chartData, options, updateMode]); + + return ( + + + + + + + ); +} diff --git a/src/components/charts/ChartTooltip.tsx b/src/components/charts/ChartTooltip.tsx new file mode 100644 index 0000000..95ba2a2 --- /dev/null +++ b/src/components/charts/ChartTooltip.tsx @@ -0,0 +1,23 @@ +import { Column, FloatingTooltip, Row, StatusLight } from '@umami/react-zen'; +import type { ReactNode } from 'react'; + +export function ChartTooltip({ + title, + color, + value, +}: { + title?: string; + color?: string; + value?: ReactNode; +}) { + return ( + + + {title && {title}} + + {value} + + + + ); +} diff --git a/src/components/charts/PieChart.tsx b/src/components/charts/PieChart.tsx new file mode 100644 index 0000000..2470fe7 --- /dev/null +++ b/src/components/charts/PieChart.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { Chart, type ChartProps } from '@/components/charts/Chart'; +import { ChartTooltip } from '@/components/charts/ChartTooltip'; + +export interface PieChartProps extends ChartProps { + type?: 'doughnut' | 'pie'; +} + +export function PieChart({ type = 'pie', ...props }: PieChartProps) { + const [tooltip, setTooltip] = useState(null); + + const handleTooltip = ({ tooltip }) => { + const { opacity, labelColors, title, dataPoints } = tooltip; + + setTooltip( + opacity + ? { + color: labelColors?.[0]?.backgroundColor, + value: `${title}: ${dataPoints[0].raw}`, + } + : null, + ); + }; + + return ( + <> + + {tooltip && } + + ); +} diff --git a/src/components/common/ActionForm.tsx b/src/components/common/ActionForm.tsx new file mode 100644 index 0000000..c6f44e8 --- /dev/null +++ b/src/components/common/ActionForm.tsx @@ -0,0 +1,15 @@ +import { Column, Row, Text } from '@umami/react-zen'; + +export function ActionForm({ label, description, children }) { + return ( + + + {label} + {description} + + + {children} + + + ); +} diff --git a/src/components/common/AnimatedDiv.tsx b/src/components/common/AnimatedDiv.tsx new file mode 100644 index 0000000..f994897 --- /dev/null +++ b/src/components/common/AnimatedDiv.tsx @@ -0,0 +1,3 @@ +import { type AnimatedComponent, animated } from '@react-spring/web'; + +export const AnimatedDiv: AnimatedComponent = animated.div; diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx new file mode 100644 index 0000000..9b198b3 --- /dev/null +++ b/src/components/common/Avatar.tsx @@ -0,0 +1,21 @@ +import { lorelei } from '@dicebear/collection'; +import { createAvatar } from '@dicebear/core'; +import { useMemo } from 'react'; +import { getColor, getPastel } from '@/lib/colors'; + +const lib = lorelei; + +export function Avatar({ seed, size = 128, ...props }: { seed: string; size?: number }) { + const backgroundColor = getPastel(getColor(seed), 4); + + const avatar = useMemo(() => { + return createAvatar(lib, { + ...props, + seed, + size, + backgroundColor: [backgroundColor], + }).toDataUri(); + }, []); + + return Avatar; +} diff --git a/src/components/common/ConfirmationForm.tsx b/src/components/common/ConfirmationForm.tsx new file mode 100644 index 0000000..b909ef5 --- /dev/null +++ b/src/components/common/ConfirmationForm.tsx @@ -0,0 +1,42 @@ +import { Box, Button, Form, FormButtons, FormSubmitButton } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { useMessages } from '@/components/hooks'; + +export interface ConfirmationFormProps { + message: ReactNode; + buttonLabel?: ReactNode; + buttonVariant?: 'primary' | 'quiet' | 'danger'; + isLoading?: boolean; + error?: string | Error; + onConfirm?: () => void; + onClose?: () => void; +} + +export function ConfirmationForm({ + message, + buttonLabel, + buttonVariant, + isLoading, + error, + onConfirm, + onClose, +}: ConfirmationFormProps) { + const { formatMessage, labels, getErrorMessage } = useMessages(); + + return ( +
+ {message} + + + + {buttonLabel || formatMessage(labels.ok)} + + +
+ ); +} diff --git a/src/components/common/DataGrid.tsx b/src/components/common/DataGrid.tsx new file mode 100644 index 0000000..7e07b8d --- /dev/null +++ b/src/components/common/DataGrid.tsx @@ -0,0 +1,107 @@ +import type { UseQueryResult } from '@tanstack/react-query'; +import { Column, Row, SearchField } from '@umami/react-zen'; +import { + cloneElement, + isValidElement, + type ReactElement, + type ReactNode, + useCallback, + useState, +} from 'react'; +import { Empty } from '@/components/common/Empty'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Pager } from '@/components/common/Pager'; +import { useMessages, useMobile, useNavigation } from '@/components/hooks'; +import type { PageResult } from '@/lib/types'; + +const DEFAULT_SEARCH_DELAY = 600; + +export interface DataGridProps { + query: UseQueryResult, any>; + searchDelay?: number; + allowSearch?: boolean; + allowPaging?: boolean; + autoFocus?: boolean; + renderActions?: () => ReactNode; + renderEmpty?: () => ReactNode; + children: ReactNode | ((data: any) => ReactNode); +} + +export function DataGrid({ + query, + searchDelay = 600, + allowSearch, + allowPaging = true, + autoFocus, + renderActions, + renderEmpty = () => , + children, +}: DataGridProps) { + const { formatMessage, labels } = useMessages(); + const { data, error, isLoading, isFetching } = query; + const { router, updateParams, query: queryParams } = useNavigation(); + const [search, setSearch] = useState(queryParams?.search || data?.search || ''); + const showPager = allowPaging && data && data.count > data.pageSize; + const { isMobile } = useMobile(); + const displayMode = isMobile ? 'cards' : undefined; + + const handleSearch = (value: string) => { + if (value !== search) { + setSearch(value); + router.push(updateParams({ search: value, page: 1 })); + } + }; + + const handlePageChange = useCallback( + (page: number) => { + router.push(updateParams({ search, page })); + }, + [search], + ); + + const child = data ? (typeof children === 'function' ? children(data) : children) : null; + + return ( + + {allowSearch && ( + + + {renderActions?.()} + + )} + + {data && ( + <> + + {isValidElement(child) + ? cloneElement(child as ReactElement, { displayMode }) + : child} + + {showPager && ( + + + + )} + + )} + + + ); +} diff --git a/src/components/common/DateDisplay.tsx b/src/components/common/DateDisplay.tsx new file mode 100644 index 0000000..0bece8a --- /dev/null +++ b/src/components/common/DateDisplay.tsx @@ -0,0 +1,28 @@ +import { Icon, Row, Text } from '@umami/react-zen'; +import { differenceInDays, isSameDay } from 'date-fns'; +import { useLocale } from '@/components/hooks'; +import { Calendar } from '@/components/icons'; +import { formatDate } from '@/lib/date'; + +export function DateDisplay({ startDate, endDate }) { + const { locale } = useLocale(); + const isSingleDate = differenceInDays(endDate, startDate) === 0; + + return ( + + + + + + {isSingleDate ? ( + formatDate(startDate, 'PP', locale) + ) : ( + <> + {formatDate(startDate, 'PP', locale)} + {!isSameDay(startDate, endDate) && ` — ${formatDate(endDate, 'PP', locale)}`} + + )} + + + ); +} diff --git a/src/components/common/DateDistance.tsx b/src/components/common/DateDistance.tsx new file mode 100644 index 0000000..e8bd278 --- /dev/null +++ b/src/components/common/DateDistance.tsx @@ -0,0 +1,19 @@ +import { Text } from '@umami/react-zen'; +import { formatDistanceToNow } from 'date-fns'; +import { useLocale, useTimezone } from '@/components/hooks'; +import { isInvalidDate } from '@/lib/date'; + +export function DateDistance({ date }: { date: Date }) { + const { formatTimezoneDate } = useTimezone(); + const { dateLocale } = useLocale(); + + if (isInvalidDate(date)) { + return null; + } + + return ( + + {formatDistanceToNow(date, { addSuffix: true, locale: dateLocale })} + + ); +} diff --git a/src/components/common/Empty.tsx b/src/components/common/Empty.tsx new file mode 100644 index 0000000..8bd8d82 --- /dev/null +++ b/src/components/common/Empty.tsx @@ -0,0 +1,24 @@ +import { Row } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; + +export interface EmptyProps { + message?: string; +} + +export function Empty({ message }: EmptyProps) { + const { formatMessage, messages } = useMessages(); + + return ( + + {message || formatMessage(messages.noDataAvailable)} + + ); +} diff --git a/src/components/common/EmptyPlaceholder.tsx b/src/components/common/EmptyPlaceholder.tsx new file mode 100644 index 0000000..64492e0 --- /dev/null +++ b/src/components/common/EmptyPlaceholder.tsx @@ -0,0 +1,28 @@ +import { Column, Icon, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; + +export interface EmptyPlaceholderProps { + title?: string; + description?: string; + icon?: ReactNode; + children?: ReactNode; +} + +export function EmptyPlaceholder({ title, description, icon, children }: EmptyPlaceholderProps) { + return ( + + {icon && ( + + {icon} + + )} + {title && ( + + {title} + + )} + {description && {description}} + {children} + + ); +} diff --git a/src/components/common/ErrorBoundary.tsx b/src/components/common/ErrorBoundary.tsx new file mode 100644 index 0000000..4c0c82e --- /dev/null +++ b/src/components/common/ErrorBoundary.tsx @@ -0,0 +1,38 @@ +import { Button, Column } from '@umami/react-zen'; +import type { ErrorInfo, ReactNode } from 'react'; +import { ErrorBoundary as Boundary } from 'react-error-boundary'; +import { useMessages } from '@/components/hooks'; + +const logError = (error: Error, info: ErrorInfo) => { + // eslint-disable-next-line no-console + console.error(error, info.componentStack); +}; + +export function ErrorBoundary({ children }: { children: ReactNode }) { + const { formatMessage, messages } = useMessages(); + + const fallbackRender = ({ error, resetErrorBoundary }) => { + return ( + +

{formatMessage(messages.error)}

+

{error.message}

+
{error.stack}
+ +
+ ); + }; + + return ( + + {children} + + ); +} diff --git a/src/components/common/ErrorMessage.tsx b/src/components/common/ErrorMessage.tsx new file mode 100644 index 0000000..3c30151 --- /dev/null +++ b/src/components/common/ErrorMessage.tsx @@ -0,0 +1,16 @@ +import { Icon, Row, Text } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { AlertTriangle } from '@/components/icons'; + +export function ErrorMessage() { + const { formatMessage, messages } = useMessages(); + + return ( + + + + + {formatMessage(messages.error)} + + ); +} diff --git a/src/components/common/ExternalLink.tsx b/src/components/common/ExternalLink.tsx new file mode 100644 index 0000000..dec0d16 --- /dev/null +++ b/src/components/common/ExternalLink.tsx @@ -0,0 +1,23 @@ +import { Icon, Row, Text } from '@umami/react-zen'; +import Link, { type LinkProps } from 'next/link'; +import type { ReactNode } from 'react'; +import { ExternalLink as LinkIcon } from '@/components/icons'; + +export function ExternalLink({ + href, + children, + ...props +}: LinkProps & { href: string; children: ReactNode }) { + return ( + + + + {children} + + + + + + + ); +} diff --git a/src/components/common/Favicon.tsx b/src/components/common/Favicon.tsx new file mode 100644 index 0000000..a6b5e52 --- /dev/null +++ b/src/components/common/Favicon.tsx @@ -0,0 +1,22 @@ +import { useConfig } from '@/components/hooks'; +import { FAVICON_URL, GROUPED_DOMAINS } from '@/lib/constants'; + +function getHostName(url: string) { + const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?([^:/\n?=]+)/im); + return match && match.length > 1 ? match[1] : null; +} + +export function Favicon({ domain, ...props }) { + const config = useConfig(); + + if (config?.privateMode) { + return null; + } + + const url = config?.faviconUrl || FAVICON_URL; + const hostName = domain ? getHostName(domain) : null; + const domainName = GROUPED_DOMAINS[hostName]?.domain || hostName; + const src = hostName ? url.replace(/\{\{\s*domain\s*}}/, domainName) : null; + + return hostName ? : null; +} diff --git a/src/components/common/FilterLink.tsx b/src/components/common/FilterLink.tsx new file mode 100644 index 0000000..d719a37 --- /dev/null +++ b/src/components/common/FilterLink.tsx @@ -0,0 +1,49 @@ +import { Icon, Row, Text } from '@umami/react-zen'; +import Link from 'next/link'; +import { type HTMLAttributes, type ReactNode, useState } from 'react'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { ExternalLink } from '@/components/icons'; + +export interface FilterLinkProps extends HTMLAttributes { + type: string; + value: string; + label?: string; + icon?: ReactNode; + externalUrl?: string; +} + +export function FilterLink({ type, value, label, externalUrl, icon }: FilterLinkProps) { + const [showLink, setShowLink] = useState(false); + const { formatMessage, labels } = useMessages(); + const { updateParams, query } = useNavigation(); + const active = query[type] !== undefined; + const selected = query[type] === value; + + return ( + setShowLink(true)} + onMouseOut={() => setShowLink(false)} + > + {icon} + {!value && `(${label || formatMessage(labels.unknown)})`} + {value && ( + + + {label || value} + + + )} + {externalUrl && showLink && ( + + + + + + )} + + ); +} diff --git a/src/components/common/FilterRecord.tsx b/src/components/common/FilterRecord.tsx new file mode 100644 index 0000000..0400264 --- /dev/null +++ b/src/components/common/FilterRecord.tsx @@ -0,0 +1,117 @@ +import { Button, Column, Grid, Icon, Label, ListItem, Select, TextField } from '@umami/react-zen'; +import { useState } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { useFilters, useFormat, useWebsiteValuesQuery } from '@/components/hooks'; +import { X } from '@/components/icons'; +import { isSearchOperator } from '@/lib/params'; + +export interface FilterRecordProps { + websiteId: string; + type: string; + startDate: Date; + endDate: Date; + name: string; + operator: string; + value: string; + onSelect?: (name: string, value: any) => void; + onRemove?: (name: string) => void; + onChange?: (name: string, value: string) => void; +} + +export function FilterRecord({ + websiteId, + type, + startDate, + endDate, + name, + operator, + value, + onSelect, + onRemove, + onChange, +}: FilterRecordProps) { + const { fields, operators } = useFilters(); + const [selected, setSelected] = useState(value); + const [search, setSearch] = useState(''); + const { formatValue } = useFormat(); + const { data, isLoading } = useWebsiteValuesQuery({ + websiteId, + type, + search, + startDate, + endDate, + }); + const isSearch = isSearchOperator(operator); + const items = data?.filter(({ value }) => value) || []; + + const handleSearch = (value: string) => { + setSearch(value); + }; + + const handleSelectOperator = (value: any) => { + onSelect?.(name, value); + }; + + const handleSelectValue = (value: string) => { + setSelected(value); + onChange?.(name, value); + }; + + const renderValue = () => { + return formatValue(selected, type); + }; + + return ( + + + + + + {isSearch && ( + + )} + {!isSearch && ( + + )} + + + + + + + ); +} diff --git a/src/components/common/GridRow.tsx b/src/components/common/GridRow.tsx new file mode 100644 index 0000000..72f1db6 --- /dev/null +++ b/src/components/common/GridRow.tsx @@ -0,0 +1,32 @@ +import { Grid } from '@umami/react-zen'; + +const LAYOUTS = { + one: { columns: '1fr' }, + two: { + columns: { + xs: '1fr', + md: 'repeat(auto-fill, minmax(560px, 1fr))', + }, + }, + three: { + columns: { + xs: '1fr', + md: 'repeat(auto-fill, minmax(360px, 1fr))', + }, + }, + 'one-two': { columns: { xs: '1fr', md: 'repeat(3, 1fr)' } }, + 'two-one': { columns: { xs: '1fr', md: 'repeat(3, 1fr)' } }, +}; + +export function GridRow(props: { + layout?: 'one' | 'two' | 'three' | 'one-two' | 'two-one' | 'compare'; + className?: string; + children?: any; +}) { + const { layout = 'two', children, ...otherProps } = props; + return ( + + {children} + + ); +} diff --git a/src/components/common/LinkButton.tsx b/src/components/common/LinkButton.tsx new file mode 100644 index 0000000..35292ba --- /dev/null +++ b/src/components/common/LinkButton.tsx @@ -0,0 +1,41 @@ +import { Button, type ButtonProps } from '@umami/react-zen'; +import Link from 'next/link'; +import type { ReactNode } from 'react'; +import { useLocale } from '@/components/hooks'; + +export interface LinkButtonProps extends ButtonProps { + href: string; + target?: string; + scroll?: boolean; + variant?: any; + prefetch?: boolean; + asAnchor?: boolean; + children?: ReactNode; +} + +export function LinkButton({ + href, + variant, + scroll = true, + target, + prefetch, + children, + asAnchor, + ...props +}: LinkButtonProps) { + const { dir } = useLocale(); + + return ( + + ); +} diff --git a/src/components/common/LoadingPanel.tsx b/src/components/common/LoadingPanel.tsx new file mode 100644 index 0000000..fb37e14 --- /dev/null +++ b/src/components/common/LoadingPanel.tsx @@ -0,0 +1,71 @@ +import { Column, type ColumnProps, Loading } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { ErrorMessage } from '@/components/common/ErrorMessage'; + +export interface LoadingPanelProps extends ColumnProps { + data?: any; + error?: unknown; + isEmpty?: boolean; + isLoading?: boolean; + isFetching?: boolean; + loadingIcon?: 'dots' | 'spinner'; + loadingPlacement?: 'center' | 'absolute' | 'inline'; + renderEmpty?: () => ReactNode; + children: ReactNode; +} + +export function LoadingPanel({ + data, + error, + isEmpty, + isLoading, + isFetching, + loadingIcon = 'dots', + loadingPlacement = 'absolute', + renderEmpty = () => , + children, + ...props +}: LoadingPanelProps): ReactNode { + const empty = isEmpty ?? checkEmpty(data); + + // Show loading spinner only if no data exists + if (isLoading || isFetching) { + return ( + + + + ); + } + + // Show error + if (error) { + return ; + } + + // Show empty state (once loaded) + if (!error && !isLoading && !isFetching && empty) { + return renderEmpty(); + } + + // Show main content when data exists + if (!isLoading && !isFetching && !error && !empty) { + return children; + } + + return null; +} + +function checkEmpty(data: any) { + if (!data) return false; + + if (Array.isArray(data)) { + return data.length <= 0; + } + + if (typeof data === 'object') { + return Object.keys(data).length <= 0; + } + + return !!data; +} diff --git a/src/components/common/PageBody.tsx b/src/components/common/PageBody.tsx new file mode 100644 index 0000000..f07e589 --- /dev/null +++ b/src/components/common/PageBody.tsx @@ -0,0 +1,42 @@ +'use client'; +import { AlertBanner, Column, type ColumnProps, Loading } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { useMessages } from '@/components/hooks'; + +const DEFAULT_WIDTH = '1320px'; + +export function PageBody({ + maxWidth = DEFAULT_WIDTH, + error, + isLoading, + children, + ...props +}: { + maxWidth?: string; + error?: unknown; + isLoading?: boolean; + children?: ReactNode; +} & ColumnProps) { + const { formatMessage, messages } = useMessages(); + + if (error) { + return ; + } + + if (isLoading) { + return ; + } + + return ( + + {children} + + ); +} diff --git a/src/components/common/PageHeader.tsx b/src/components/common/PageHeader.tsx new file mode 100644 index 0000000..9216788 --- /dev/null +++ b/src/components/common/PageHeader.tsx @@ -0,0 +1,58 @@ +import { Column, Grid, Heading, Icon, Row, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { LinkButton } from './LinkButton'; + +export function PageHeader({ + title, + description, + label, + icon, + showBorder = true, + titleHref, + children, +}: { + title: string; + description?: string; + label?: ReactNode; + icon?: ReactNode; + showBorder?: boolean; + titleHref?: string; + allowEdit?: boolean; + className?: string; + children?: ReactNode; +}) { + return ( + + + {label} + + {icon && ( + + {icon} + + )} + {title && titleHref ? ( + + {title} + + ) : ( + title && {title} + )} + + {description && ( + + {description} + + )} + + + {children} + + + ); +} diff --git a/src/components/common/Pager.tsx b/src/components/common/Pager.tsx new file mode 100644 index 0000000..c65e2f6 --- /dev/null +++ b/src/components/common/Pager.tsx @@ -0,0 +1,60 @@ +import { Button, Icon, Row, Text } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { ChevronRight } from '@/components/icons'; + +export interface PagerProps { + page: string | number; + pageSize: string | number; + count: string | number; + onPageChange: (nextPage: number) => void; + className?: string; +} + +export function Pager({ page, pageSize, count, onPageChange }: PagerProps) { + const { formatMessage, labels } = useMessages(); + const maxPage = pageSize && count ? Math.ceil(+count / +pageSize) : 0; + const lastPage = page === maxPage; + const firstPage = page === 1; + + if (count === 0 || !maxPage) { + return null; + } + + const handlePageChange = (value: number) => { + const nextPage = +page + +value; + + if (nextPage > 0 && nextPage <= maxPage) { + onPageChange(nextPage); + } + }; + + if (maxPage === 1) { + return null; + } + + return ( + + {formatMessage(labels.numberOfRecords, { x: count.toLocaleString() })} + + + {formatMessage(labels.pageOf, { + current: page.toLocaleString(), + total: maxPage.toLocaleString(), + })} + + + + + + + + ); +} diff --git a/src/components/common/Panel.tsx b/src/components/common/Panel.tsx new file mode 100644 index 0000000..bb66746 --- /dev/null +++ b/src/components/common/Panel.tsx @@ -0,0 +1,64 @@ +import { + Button, + Column, + type ColumnProps, + Heading, + Icon, + Row, + Tooltip, + TooltipTrigger, +} from '@umami/react-zen'; +import { useState } from 'react'; +import { useMessages } from '@/components/hooks'; +import { Maximize, X } from '@/components/icons'; + +export interface PanelProps extends ColumnProps { + title?: string; + allowFullscreen?: boolean; +} + +const fullscreenStyles = { + position: 'fixed', + width: '100%', + height: '100%', + top: 0, + left: 0, + border: 'none', + zIndex: 9999, +} as any; + +export function Panel({ title, allowFullscreen, style, children, ...props }: PanelProps) { + const { formatMessage, labels } = useMessages(); + const [isFullscreen, setIsFullscreen] = useState(false); + + const handleFullscreen = () => { + setIsFullscreen(!isFullscreen); + }; + + return ( + + {title && {title}} + {allowFullscreen && ( + + + + {formatMessage(labels.maximize)} + + + )} + {children} + + ); +} diff --git a/src/components/common/SectionHeader.tsx b/src/components/common/SectionHeader.tsx new file mode 100644 index 0000000..5b911ef --- /dev/null +++ b/src/components/common/SectionHeader.tsx @@ -0,0 +1,28 @@ +import { Heading, Icon, Row, type RowProps, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; + +export function SectionHeader({ + title, + description, + icon, + children, + ...props +}: { + title?: string; + description?: string; + icon?: ReactNode; + allowEdit?: boolean; + className?: string; + children?: ReactNode; +} & RowProps) { + return ( + + + {icon && {icon}} + {title && {title}} + {description && {description}} + + {children} + + ); +} diff --git a/src/components/common/SideMenu.tsx b/src/components/common/SideMenu.tsx new file mode 100644 index 0000000..92ff798 --- /dev/null +++ b/src/components/common/SideMenu.tsx @@ -0,0 +1,80 @@ +import { + Column, + Heading, + IconLabel, + NavMenu, + NavMenuGroup, + NavMenuItem, + type NavMenuProps, + Row, +} from '@umami/react-zen'; +import Link from 'next/link'; + +interface SideMenuData { + id: string; + label: string; + icon?: any; + path: string; +} + +interface SideMenuItems { + label?: string; + items: SideMenuData[]; +} + +export interface SideMenuProps extends NavMenuProps { + items: SideMenuItems[]; + title?: string; + selectedKey?: string; + allowMinimize?: boolean; +} + +export function SideMenu({ + items = [], + title, + selectedKey, + allowMinimize, + ...props +}: SideMenuProps) { + const renderItems = (items: SideMenuData[]) => { + return items?.map(({ id, label, icon, path }) => { + const isSelected = selectedKey === id; + + return ( + + + {label} + + + ); + }); + }; + + return ( + + {title && ( + + {title} + + )} + + {items?.map(({ label, items }, index) => { + if (label) { + return ( + + {renderItems(items)} + + ); + } + return null; + })} + + + ); +} diff --git a/src/components/common/TypeConfirmationForm.tsx b/src/components/common/TypeConfirmationForm.tsx new file mode 100644 index 0000000..1121fa7 --- /dev/null +++ b/src/components/common/TypeConfirmationForm.tsx @@ -0,0 +1,55 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + TextField, +} from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; + +export function TypeConfirmationForm({ + confirmationValue, + buttonLabel, + buttonVariant, + isLoading, + error, + onConfirm, + onClose, +}: { + confirmationValue: string; + buttonLabel?: string; + buttonVariant?: 'primary' | 'outline' | 'quiet' | 'danger' | 'zero'; + isLoading?: boolean; + error?: string | Error; + onConfirm?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + if (!confirmationValue) { + return null; + } + + return ( +
+

+ {formatMessage(messages.actionConfirmation, { + confirmation: confirmationValue, + })} +

+ value === confirmationValue }} + > + + + + + + {buttonLabel || formatMessage(labels.ok)} + + +
+ ); +} diff --git a/src/components/common/TypeIcon.tsx b/src/components/common/TypeIcon.tsx new file mode 100644 index 0000000..8894b3a --- /dev/null +++ b/src/components/common/TypeIcon.tsx @@ -0,0 +1,29 @@ +import { Row } from '@umami/react-zen'; +import type { ReactNode } from 'react'; + +export function TypeIcon({ + type, + value, + children, +}: { + type: 'browser' | 'country' | 'device' | 'os'; + value: string; + children?: ReactNode; +}) { + return ( + + { + e.currentTarget.src = `${process.env.basePath || ''}/images/${type}/unknown.png`; + }} + alt={value} + width={type === 'country' ? undefined : 16} + height={type === 'country' ? undefined : 16} + /> + {children} + + ); +} diff --git a/src/components/hooks/context/useLink.ts b/src/components/hooks/context/useLink.ts new file mode 100644 index 0000000..8766bbb --- /dev/null +++ b/src/components/hooks/context/useLink.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { LinkContext } from '@/app/(main)/links/LinkProvider'; + +export function useLink() { + return useContext(LinkContext); +} diff --git a/src/components/hooks/context/usePixel.ts b/src/components/hooks/context/usePixel.ts new file mode 100644 index 0000000..69cad6f --- /dev/null +++ b/src/components/hooks/context/usePixel.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { PixelContext } from '@/app/(main)/pixels/PixelProvider'; + +export function usePixel() { + return useContext(PixelContext); +} diff --git a/src/components/hooks/context/useTeam.ts b/src/components/hooks/context/useTeam.ts new file mode 100644 index 0000000..95ff4be --- /dev/null +++ b/src/components/hooks/context/useTeam.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { TeamContext } from '@/app/(main)/teams/TeamProvider'; + +export function useTeam() { + return useContext(TeamContext); +} diff --git a/src/components/hooks/context/useUser.ts b/src/components/hooks/context/useUser.ts new file mode 100644 index 0000000..fa97ea9 --- /dev/null +++ b/src/components/hooks/context/useUser.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { UserContext } from '@/app/(main)/admin/users/[userId]/UserProvider'; + +export function useUser() { + return useContext(UserContext); +} diff --git a/src/components/hooks/context/useWebsite.ts b/src/components/hooks/context/useWebsite.ts new file mode 100644 index 0000000..3d4be27 --- /dev/null +++ b/src/components/hooks/context/useWebsite.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { WebsiteContext } from '@/app/(main)/websites/WebsiteProvider'; + +export function useWebsite() { + return useContext(WebsiteContext); +} diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts new file mode 100644 index 0000000..e8e5c13 --- /dev/null +++ b/src/components/hooks/index.ts @@ -0,0 +1,84 @@ +'use client'; + +// Context hooks +export * from './context/useLink'; +export * from './context/usePixel'; +export * from './context/useTeam'; +export * from './context/useUser'; +export * from './context/useWebsite'; + +// Query hooks +export * from './queries/useActiveUsersQuery'; +export * from './queries/useDateRangeQuery'; +export * from './queries/useDeleteQuery'; +export * from './queries/useEventDataEventsQuery'; +export * from './queries/useEventDataPropertiesQuery'; +export * from './queries/useEventDataQuery'; +export * from './queries/useEventDataValuesQuery'; +export * from './queries/useLinkQuery'; +export * from './queries/useLinksQuery'; +export * from './queries/useLoginQuery'; +export * from './queries/usePixelQuery'; +export * from './queries/usePixelsQuery'; +export * from './queries/useRealtimeQuery'; +export * from './queries/useReportQuery'; +export * from './queries/useReportsQuery'; +export * from './queries/useResultQuery'; +export * from './queries/useSessionActivityQuery'; +export * from './queries/useSessionDataPropertiesQuery'; +export * from './queries/useSessionDataQuery'; +export * from './queries/useSessionDataValuesQuery'; +export * from './queries/useShareTokenQuery'; +export * from './queries/useTeamMembersQuery'; +export * from './queries/useTeamQuery'; +export * from './queries/useTeamsQuery'; +export * from './queries/useTeamWebsitesQuery'; +export * from './queries/useUpdateQuery'; +export * from './queries/useUserQuery'; +export * from './queries/useUsersQuery'; +export * from './queries/useUserTeamsQuery'; +export * from './queries/useUserWebsitesQuery'; +export * from './queries/useWebsiteCohortQuery'; +export * from './queries/useWebsiteCohortsQuery'; +export * from './queries/useWebsiteEventsQuery'; +export * from './queries/useWebsiteEventsSeriesQuery'; +export * from './queries/useWebsiteExpandedMetricsQuery'; +export * from './queries/useWebsiteMetricsQuery'; +export * from './queries/useWebsitePageviewsQuery'; +export * from './queries/useWebsiteQuery'; +export * from './queries/useWebsiteSegmentQuery'; +export * from './queries/useWebsiteSegmentsQuery'; +export * from './queries/useWebsiteSessionQuery'; +export * from './queries/useWebsiteSessionStatsQuery'; +export * from './queries/useWebsiteSessionsQuery'; +export * from './queries/useWebsiteStatsQuery'; +export * from './queries/useWebsitesQuery'; +export * from './queries/useWebsiteValuesQuery'; +export * from './queries/useWeeklyTrafficQuery'; + +// Regular hooks +export * from './useApi'; +export * from './useConfig'; +export * from './useCountryNames'; +export * from './useDateParameters'; +export * from './useDateRange'; +export * from './useDocumentClick'; +export * from './useEscapeKey'; +export * from './useFields'; +export * from './useFilterParameters'; +export * from './useFilters'; +export * from './useForceUpdate'; +export * from './useFormat'; +export * from './useGlobalState'; +export * from './useLanguageNames'; +export * from './useLocale'; +export * from './useMessages'; +export * from './useMobile'; +export * from './useModified'; +export * from './useNavigation'; +export * from './usePagedQuery'; +export * from './usePageParameters'; +export * from './useRegionNames'; +export * from './useSlug'; +export * from './useSticky'; +export * from './useTimezone'; diff --git a/src/components/hooks/queries/useActiveUsersQuery.ts b/src/components/hooks/queries/useActiveUsersQuery.ts new file mode 100644 index 0000000..42867c1 --- /dev/null +++ b/src/components/hooks/queries/useActiveUsersQuery.ts @@ -0,0 +1,12 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; + +export function useActyiveUsersQuery(websiteId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + return useQuery({ + queryKey: ['websites:active', websiteId], + queryFn: () => get(`/websites/${websiteId}/active`), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useDateRangeQuery.ts b/src/components/hooks/queries/useDateRangeQuery.ts new file mode 100644 index 0000000..84b7eec --- /dev/null +++ b/src/components/hooks/queries/useDateRangeQuery.ts @@ -0,0 +1,23 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; + +type DateRange = { + startDate?: string; + endDate?: string; +}; + +export function useDateRangeQuery(websiteId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + + const { data } = useQuery({ + queryKey: ['date-range', websiteId], + queryFn: () => get(`/websites/${websiteId}/daterange`), + enabled: !!websiteId, + ...options, + }); + + return { + startDate: data?.startDate ? new Date(data.startDate) : null, + endDate: data?.endDate ? new Date(data.endDate) : null, + }; +} diff --git a/src/components/hooks/queries/useDeleteQuery.ts b/src/components/hooks/queries/useDeleteQuery.ts new file mode 100644 index 0000000..556231a --- /dev/null +++ b/src/components/hooks/queries/useDeleteQuery.ts @@ -0,0 +1,12 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useDeleteQuery(path: string, params?: Record) { + const { del, useMutation } = useApi(); + const query = useMutation({ + mutationFn: () => del(path, params), + }); + const { touch } = useModified(); + + return { ...query, touch }; +} diff --git a/src/components/hooks/queries/useEventDataEventsQuery.ts b/src/components/hooks/queries/useEventDataEventsQuery.ts new file mode 100644 index 0000000..1401989 --- /dev/null +++ b/src/components/hooks/queries/useEventDataEventsQuery.ts @@ -0,0 +1,27 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useEventDataEventsQuery(websiteId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: [ + 'websites:event-data:events', + { websiteId, startAt, endAt, unit, timezone, ...filters }, + ], + queryFn: () => + get(`/websites/${websiteId}/event-data/events`, { + startAt, + endAt, + unit, + timezone, + ...filters, + }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useEventDataPropertiesQuery.ts b/src/components/hooks/queries/useEventDataPropertiesQuery.ts new file mode 100644 index 0000000..dfa6e92 --- /dev/null +++ b/src/components/hooks/queries/useEventDataPropertiesQuery.ts @@ -0,0 +1,27 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useEventDataPropertiesQuery(websiteId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: [ + 'websites:event-data:properties', + { websiteId, startAt, endAt, unit, timezone, ...filters }, + ], + queryFn: () => + get(`/websites/${websiteId}/event-data/properties`, { + startAt, + endAt, + unit, + timezone, + ...filters, + }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useEventDataQuery.ts b/src/components/hooks/queries/useEventDataQuery.ts new file mode 100644 index 0000000..2ccbd63 --- /dev/null +++ b/src/components/hooks/queries/useEventDataQuery.ts @@ -0,0 +1,27 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useEventDataQuery(websiteId: string, eventId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const params = useFilterParameters(); + + return useQuery({ + queryKey: [ + 'websites:event-data', + { websiteId, eventId, startAt, endAt, unit, timezone, ...params }, + ], + queryFn: () => + get(`/websites/${websiteId}/event-data/${eventId}`, { + startAt, + endAt, + unit, + timezone, + ...params, + }), + enabled: !!(websiteId && eventId), + ...options, + }); +} diff --git a/src/components/hooks/queries/useEventDataValuesQuery.ts b/src/components/hooks/queries/useEventDataValuesQuery.ts new file mode 100644 index 0000000..6529e14 --- /dev/null +++ b/src/components/hooks/queries/useEventDataValuesQuery.ts @@ -0,0 +1,34 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useEventDataValuesQuery( + websiteId: string, + event: string, + propertyName: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: [ + 'websites:event-data:values', + { websiteId, event, propertyName, startAt, endAt, unit, timezone, ...filters }, + ], + queryFn: () => + get(`/websites/${websiteId}/event-data/values`, { + startAt, + endAt, + unit, + timezone, + ...filters, + event, + propertyName, + }), + enabled: !!(websiteId && propertyName), + ...options, + }); +} diff --git a/src/components/hooks/queries/useLinkQuery.ts b/src/components/hooks/queries/useLinkQuery.ts new file mode 100644 index 0000000..2a5d4a9 --- /dev/null +++ b/src/components/hooks/queries/useLinkQuery.ts @@ -0,0 +1,15 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useLinkQuery(linkId: string) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`link:${linkId}`); + + return useQuery({ + queryKey: ['link', { linkId, modified }], + queryFn: () => { + return get(`/links/${linkId}`); + }, + enabled: !!linkId, + }); +} diff --git a/src/components/hooks/queries/useLinksQuery.ts b/src/components/hooks/queries/useLinksQuery.ts new file mode 100644 index 0000000..ebf945f --- /dev/null +++ b/src/components/hooks/queries/useLinksQuery.ts @@ -0,0 +1,17 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useLinksQuery({ teamId }: { teamId?: string }, options?: ReactQueryOptions) { + const { modified } = useModified('links'); + const { get } = useApi(); + + return usePagedQuery({ + queryKey: ['links', { teamId, modified }], + queryFn: pageParams => { + return get(teamId ? `/teams/${teamId}/links` : '/links', pageParams); + }, + ...options, + }); +} diff --git a/src/components/hooks/queries/useLoginQuery.ts b/src/components/hooks/queries/useLoginQuery.ts new file mode 100644 index 0000000..a64b784 --- /dev/null +++ b/src/components/hooks/queries/useLoginQuery.ts @@ -0,0 +1,23 @@ +import { setUser, useApp } from '@/store/app'; +import { useApi } from '../useApi'; + +const selector = (state: { user: any }) => state.user; + +export function useLoginQuery() { + const { post, useQuery } = useApi(); + const user = useApp(selector); + + const query = useQuery({ + queryKey: ['login'], + queryFn: async () => { + const data = await post('/auth/verify'); + + setUser(data); + + return data; + }, + enabled: !user, + }); + + return { user, setUser, ...query }; +} diff --git a/src/components/hooks/queries/usePixelQuery.ts b/src/components/hooks/queries/usePixelQuery.ts new file mode 100644 index 0000000..7fd83c2 --- /dev/null +++ b/src/components/hooks/queries/usePixelQuery.ts @@ -0,0 +1,15 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function usePixelQuery(pixelId: string) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`pixel:${pixelId}`); + + return useQuery({ + queryKey: ['pixel', { pixelId, modified }], + queryFn: () => { + return get(`/pixels/${pixelId}`); + }, + enabled: !!pixelId, + }); +} diff --git a/src/components/hooks/queries/usePixelsQuery.ts b/src/components/hooks/queries/usePixelsQuery.ts new file mode 100644 index 0000000..c431179 --- /dev/null +++ b/src/components/hooks/queries/usePixelsQuery.ts @@ -0,0 +1,17 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function usePixelsQuery({ teamId }: { teamId?: string }, options?: ReactQueryOptions) { + const { modified } = useModified('pixels'); + const { get } = useApi(); + + return usePagedQuery({ + queryKey: ['pixels', { teamId, modified }], + queryFn: pageParams => { + return get(teamId ? `/teams/${teamId}/pixels` : '/pixels', pageParams); + }, + ...options, + }); +} diff --git a/src/components/hooks/queries/useRealtimeQuery.ts b/src/components/hooks/queries/useRealtimeQuery.ts new file mode 100644 index 0000000..1a5bd1c --- /dev/null +++ b/src/components/hooks/queries/useRealtimeQuery.ts @@ -0,0 +1,17 @@ +import { REALTIME_INTERVAL } from '@/lib/constants'; +import type { RealtimeData } from '@/lib/types'; +import { useApi } from '../useApi'; + +export function useRealtimeQuery(websiteId: string) { + const { get, useQuery } = useApi(); + const { data, isLoading, error } = useQuery({ + queryKey: ['realtime', { websiteId }], + queryFn: async () => { + return get(`/realtime/${websiteId}`); + }, + enabled: !!websiteId, + refetchInterval: REALTIME_INTERVAL, + }); + + return { data, isLoading, error }; +} diff --git a/src/components/hooks/queries/useReportQuery.ts b/src/components/hooks/queries/useReportQuery.ts new file mode 100644 index 0000000..6973e2d --- /dev/null +++ b/src/components/hooks/queries/useReportQuery.ts @@ -0,0 +1,15 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useReportQuery(reportId: string) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`report:${reportId}`); + + return useQuery({ + queryKey: ['report', { reportId, modified }], + queryFn: () => { + return get(`/reports/${reportId}`); + }, + enabled: !!reportId, + }); +} diff --git a/src/components/hooks/queries/useReportsQuery.ts b/src/components/hooks/queries/useReportsQuery.ts new file mode 100644 index 0000000..ba1bdd4 --- /dev/null +++ b/src/components/hooks/queries/useReportsQuery.ts @@ -0,0 +1,19 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useReportsQuery( + { websiteId, type }: { websiteId: string; type?: string }, + options?: ReactQueryOptions, +) { + const { modified } = useModified(`reports:${type}`); + const { get } = useApi(); + + return usePagedQuery({ + queryKey: ['reports', { websiteId, type, modified }], + queryFn: async () => get('/reports', { websiteId, type }), + enabled: !!websiteId && !!type, + ...options, + }); +} diff --git a/src/components/hooks/queries/useResultQuery.ts b/src/components/hooks/queries/useResultQuery.ts new file mode 100644 index 0000000..c6fce12 --- /dev/null +++ b/src/components/hooks/queries/useResultQuery.ts @@ -0,0 +1,44 @@ +import { useDateParameters } from '@/components/hooks/useDateParameters'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useResultQuery( + type: string, + params?: Record, + options?: ReactQueryOptions, +) { + const { websiteId, ...parameters } = params; + const { post, useQuery } = useApi(); + const { startDate, endDate, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: [ + 'reports', + { + type, + websiteId, + startDate, + endDate, + timezone, + ...params, + ...filters, + }, + ], + queryFn: () => + post(`/reports/${type}`, { + websiteId, + type, + filters, + parameters: { + startDate, + endDate, + timezone, + ...parameters, + }, + }), + enabled: !!type, + ...options, + }); +} diff --git a/src/components/hooks/queries/useSessionActivityQuery.ts b/src/components/hooks/queries/useSessionActivityQuery.ts new file mode 100644 index 0000000..d8d34ac --- /dev/null +++ b/src/components/hooks/queries/useSessionActivityQuery.ts @@ -0,0 +1,21 @@ +import { useApi } from '../useApi'; + +export function useSessionActivityQuery( + websiteId: string, + sessionId: string, + startDate: Date, + endDate: Date, +) { + const { get, useQuery } = useApi(); + + return useQuery({ + queryKey: ['session:activity', { websiteId, sessionId, startDate, endDate }], + queryFn: () => { + return get(`/websites/${websiteId}/sessions/${sessionId}/activity`, { + startAt: +new Date(startDate), + endAt: +new Date(endDate), + }); + }, + enabled: Boolean(websiteId && sessionId && startDate && endDate), + }); +} diff --git a/src/components/hooks/queries/useSessionDataPropertiesQuery.ts b/src/components/hooks/queries/useSessionDataPropertiesQuery.ts new file mode 100644 index 0000000..ac651bb --- /dev/null +++ b/src/components/hooks/queries/useSessionDataPropertiesQuery.ts @@ -0,0 +1,27 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useSessionDataPropertiesQuery(websiteId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: [ + 'websites:session-data:properties', + { websiteId, startAt, endAt, unit, timezone, ...filters }, + ], + queryFn: () => + get(`/websites/${websiteId}/session-data/properties`, { + startAt, + endAt, + unit, + timezone, + ...filters, + }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useSessionDataQuery.ts b/src/components/hooks/queries/useSessionDataQuery.ts new file mode 100644 index 0000000..62b5398 --- /dev/null +++ b/src/components/hooks/queries/useSessionDataQuery.ts @@ -0,0 +1,12 @@ +import { useApi } from '../useApi'; + +export function useSessionDataQuery(websiteId: string, sessionId: string) { + const { get, useQuery } = useApi(); + + return useQuery({ + queryKey: ['session:data', { websiteId, sessionId }], + queryFn: () => { + return get(`/websites/${websiteId}/sessions/${sessionId}/properties`, { websiteId }); + }, + }); +} diff --git a/src/components/hooks/queries/useSessionDataValuesQuery.ts b/src/components/hooks/queries/useSessionDataValuesQuery.ts new file mode 100644 index 0000000..d5e180b --- /dev/null +++ b/src/components/hooks/queries/useSessionDataValuesQuery.ts @@ -0,0 +1,32 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useSessionDataValuesQuery( + websiteId: string, + propertyName: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: [ + 'websites:session-data:values', + { websiteId, propertyName, startAt, endAt, unit, timezone, ...filters }, + ], + queryFn: () => + get(`/websites/${websiteId}/session-data/values`, { + startAt, + endAt, + unit, + timezone, + ...filters, + propertyName, + }), + enabled: !!(websiteId && propertyName), + ...options, + }); +} diff --git a/src/components/hooks/queries/useShareTokenQuery.ts b/src/components/hooks/queries/useShareTokenQuery.ts new file mode 100644 index 0000000..dbad3dc --- /dev/null +++ b/src/components/hooks/queries/useShareTokenQuery.ts @@ -0,0 +1,25 @@ +import { setShareToken, useApp } from '@/store/app'; +import { useApi } from '../useApi'; + +const selector = (state: { shareToken: string }) => state.shareToken; + +export function useShareTokenQuery(shareId: string): { + shareToken: any; + isLoading?: boolean; + error?: Error; +} { + const shareToken = useApp(selector); + const { get, useQuery } = useApi(); + const { isLoading, error } = useQuery({ + queryKey: ['share', shareId], + queryFn: async () => { + const data = await get(`/share/${shareId}`); + + setShareToken(data); + + return data; + }, + }); + + return { shareToken, isLoading, error }; +} diff --git a/src/components/hooks/queries/useTeamMembersQuery.ts b/src/components/hooks/queries/useTeamMembersQuery.ts new file mode 100644 index 0000000..6f6f815 --- /dev/null +++ b/src/components/hooks/queries/useTeamMembersQuery.ts @@ -0,0 +1,16 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useTeamMembersQuery(teamId: string) { + const { get } = useApi(); + const { modified } = useModified(`teams:members`); + + return usePagedQuery({ + queryKey: ['teams:members', { teamId, modified }], + queryFn: (params: any) => { + return get(`/teams/${teamId}/users`, params); + }, + enabled: !!teamId, + }); +} diff --git a/src/components/hooks/queries/useTeamQuery.ts b/src/components/hooks/queries/useTeamQuery.ts new file mode 100644 index 0000000..c076a6a --- /dev/null +++ b/src/components/hooks/queries/useTeamQuery.ts @@ -0,0 +1,17 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useTeamQuery(teamId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`teams:${teamId}`); + + return useQuery({ + queryKey: ['teams', { teamId, modified }], + queryFn: () => get(`/teams/${teamId}`), + enabled: !!teamId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useTeamWebsitesQuery.ts b/src/components/hooks/queries/useTeamWebsitesQuery.ts new file mode 100644 index 0000000..ffe601b --- /dev/null +++ b/src/components/hooks/queries/useTeamWebsitesQuery.ts @@ -0,0 +1,15 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useTeamWebsitesQuery(teamId: string) { + const { get } = useApi(); + const { modified } = useModified(`websites`); + + return usePagedQuery({ + queryKey: ['teams:websites', { teamId, modified }], + queryFn: (params: any) => { + return get(`/teams/${teamId}/websites`, params); + }, + }); +} diff --git a/src/components/hooks/queries/useTeamsQuery.ts b/src/components/hooks/queries/useTeamsQuery.ts new file mode 100644 index 0000000..f1a09f4 --- /dev/null +++ b/src/components/hooks/queries/useTeamsQuery.ts @@ -0,0 +1,20 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useTeamsQuery(params?: Record, options?: ReactQueryOptions) { + const { get } = useApi(); + const { modified } = useModified(`teams`); + + return usePagedQuery({ + queryKey: ['teams:admin', { modified, ...params }], + queryFn: pageParams => { + return get(`/admin/teams`, { + ...pageParams, + ...params, + }); + }, + ...options, + }); +} diff --git a/src/components/hooks/queries/useUpdateQuery.ts b/src/components/hooks/queries/useUpdateQuery.ts new file mode 100644 index 0000000..85a9442 --- /dev/null +++ b/src/components/hooks/queries/useUpdateQuery.ts @@ -0,0 +1,15 @@ +import { useToast } from '@umami/react-zen'; +import type { ApiError } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useUpdateQuery(path: string, params?: Record) { + const { post, useMutation } = useApi(); + const query = useMutation>({ + mutationFn: (data: Record) => post(path, { ...data, ...params }), + }); + const { touch } = useModified(); + const { toast } = useToast(); + + return { ...query, touch, toast }; +} diff --git a/src/components/hooks/queries/useUserQuery.ts b/src/components/hooks/queries/useUserQuery.ts new file mode 100644 index 0000000..07e23f0 --- /dev/null +++ b/src/components/hooks/queries/useUserQuery.ts @@ -0,0 +1,17 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useUserQuery(userId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`user:${userId}`); + + return useQuery({ + queryKey: ['users', { userId, modified }], + queryFn: () => get(`/users/${userId}`), + enabled: !!userId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useUserTeamsQuery.ts b/src/components/hooks/queries/useUserTeamsQuery.ts new file mode 100644 index 0000000..82f6549 --- /dev/null +++ b/src/components/hooks/queries/useUserTeamsQuery.ts @@ -0,0 +1,15 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useUserTeamsQuery(userId: string) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`teams`); + + return useQuery({ + queryKey: ['teams', { userId, modified }], + queryFn: () => { + return get(`/users/${userId}/teams`); + }, + enabled: !!userId, + }); +} diff --git a/src/components/hooks/queries/useUserWebsitesQuery.ts b/src/components/hooks/queries/useUserWebsitesQuery.ts new file mode 100644 index 0000000..f98eaff --- /dev/null +++ b/src/components/hooks/queries/useUserWebsitesQuery.ts @@ -0,0 +1,31 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useUserWebsitesQuery( + { userId, teamId }: { userId?: string; teamId?: string }, + params?: Record, + options?: ReactQueryOptions, +) { + const { get } = useApi(); + const { modified } = useModified(`websites`); + + return usePagedQuery({ + queryKey: ['websites', { userId, teamId, modified, ...params }], + queryFn: pageParams => { + return get( + teamId + ? `/teams/${teamId}/websites` + : userId + ? `/users/${userId}/websites` + : '/me/websites', + { + ...pageParams, + ...params, + }, + ); + }, + ...options, + }); +} diff --git a/src/components/hooks/queries/useUsersQuery.ts b/src/components/hooks/queries/useUsersQuery.ts new file mode 100644 index 0000000..d87900b --- /dev/null +++ b/src/components/hooks/queries/useUsersQuery.ts @@ -0,0 +1,17 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useUsersQuery() { + const { get } = useApi(); + const { modified } = useModified(`users`); + + return usePagedQuery({ + queryKey: ['users:admin', { modified }], + queryFn: (pageParams: any) => { + return get('/admin/users', { + ...pageParams, + }); + }, + }); +} diff --git a/src/components/hooks/queries/useWebsiteCohortQuery.ts b/src/components/hooks/queries/useWebsiteCohortQuery.ts new file mode 100644 index 0000000..975766e --- /dev/null +++ b/src/components/hooks/queries/useWebsiteCohortQuery.ts @@ -0,0 +1,21 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useWebsiteCohortQuery( + websiteId: string, + cohortId: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`cohorts`); + + return useQuery({ + queryKey: ['website:cohorts', { websiteId, cohortId, modified }], + queryFn: () => get(`/websites/${websiteId}/segments/${cohortId}`), + enabled: !!(websiteId && cohortId), + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteCohortsQuery.ts b/src/components/hooks/queries/useWebsiteCohortsQuery.ts new file mode 100644 index 0000000..e0cbf4c --- /dev/null +++ b/src/components/hooks/queries/useWebsiteCohortsQuery.ts @@ -0,0 +1,25 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import { useFilterParameters } from '@/components/hooks/useFilterParameters'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useWebsiteCohortsQuery( + websiteId: string, + params?: Record, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`cohorts`); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: ['website:cohorts', { websiteId, modified, ...filters, ...params }], + queryFn: pageParams => { + return get(`/websites/${websiteId}/segments`, { ...pageParams, ...filters, ...params }); + }, + enabled: !!websiteId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteEventsQuery.ts b/src/components/hooks/queries/useWebsiteEventsQuery.ts new file mode 100644 index 0000000..fc4dad5 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteEventsQuery.ts @@ -0,0 +1,39 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; +import { usePagedQuery } from '../usePagedQuery'; + +const EVENT_TYPES = { + views: 1, + events: 2, +}; + +export function useWebsiteEventsQuery( + websiteId: string, + params?: Record, + options?: ReactQueryOptions, +) { + const { get } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return usePagedQuery({ + queryKey: [ + 'websites:events', + { websiteId, startAt, endAt, unit, timezone, ...filters, ...params }, + ], + queryFn: pageParams => + get(`/websites/${websiteId}/events`, { + startAt, + endAt, + unit, + timezone, + ...filters, + ...pageParams, + eventType: EVENT_TYPES[params.view], + }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts b/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts new file mode 100644 index 0000000..6c1d112 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts @@ -0,0 +1,18 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useWebsiteEventsSeriesQuery(websiteId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: ['websites:events:series', { websiteId, startAt, endAt, unit, timezone, ...filters }], + queryFn: () => + get(`/websites/${websiteId}/events/series`, { startAt, endAt, unit, timezone, ...filters }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts b/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts new file mode 100644 index 0000000..b2e9019 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts @@ -0,0 +1,51 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export type WebsiteExpandedMetricsData = { + name: string; + pageviews: number; + visitors: number; + visits: number; + bounces: number; + totaltime: number; +}[]; + +export function useWebsiteExpandedMetricsQuery( + websiteId: string, + params: { type: string; limit?: number; search?: string }, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: [ + 'websites:metrics:expanded', + { + websiteId, + startAt, + endAt, + unit, + timezone, + ...filters, + ...params, + }, + ], + queryFn: async () => + get(`/websites/${websiteId}/metrics/expanded`, { + startAt, + endAt, + unit, + timezone, + ...filters, + ...params, + }), + enabled: !!websiteId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteMetricsQuery.ts b/src/components/hooks/queries/useWebsiteMetricsQuery.ts new file mode 100644 index 0000000..67c5e4d --- /dev/null +++ b/src/components/hooks/queries/useWebsiteMetricsQuery.ts @@ -0,0 +1,47 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export type WebsiteMetricsData = { + x: string; + y: number; +}[]; + +export function useWebsiteMetricsQuery( + websiteId: string, + params: { type: string; limit?: number; search?: string }, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: [ + 'websites:metrics', + { + websiteId, + startAt, + endAt, + unit, + timezone, + ...filters, + ...params, + }, + ], + queryFn: async () => + get(`/websites/${websiteId}/metrics`, { + startAt, + endAt, + unit, + timezone, + ...filters, + ...params, + }), + enabled: !!websiteId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsitePageviewsQuery.ts b/src/components/hooks/queries/useWebsitePageviewsQuery.ts new file mode 100644 index 0000000..b35c820 --- /dev/null +++ b/src/components/hooks/queries/useWebsitePageviewsQuery.ts @@ -0,0 +1,36 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export interface WebsitePageviewsData { + pageviews: { x: string; y: number }[]; + sessions: { x: string; y: number }[]; +} + +export function useWebsitePageviewsQuery( + { websiteId, compare }: { websiteId: string; compare?: string }, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const queryParams = useFilterParameters(); + + return useQuery({ + queryKey: [ + 'websites:pageviews', + { websiteId, compare, startAt, endAt, unit, timezone, ...queryParams }, + ], + queryFn: () => + get(`/websites/${websiteId}/pageviews`, { + compare, + startAt, + endAt, + unit, + timezone, + ...queryParams, + }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteQuery.ts b/src/components/hooks/queries/useWebsiteQuery.ts new file mode 100644 index 0000000..b9a5533 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteQuery.ts @@ -0,0 +1,17 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useWebsiteQuery(websiteId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`website:${websiteId}`); + + return useQuery({ + queryKey: ['website', { websiteId, modified }], + queryFn: () => get(`/websites/${websiteId}`), + enabled: !!websiteId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteSegmentQuery.ts b/src/components/hooks/queries/useWebsiteSegmentQuery.ts new file mode 100644 index 0000000..1923fbd --- /dev/null +++ b/src/components/hooks/queries/useWebsiteSegmentQuery.ts @@ -0,0 +1,21 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useWebsiteSegmentQuery( + websiteId: string, + segmentId: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`segments`); + + return useQuery({ + queryKey: ['website:segments', { websiteId, segmentId, modified }], + queryFn: () => get(`/websites/${websiteId}/segments/${segmentId}`), + enabled: !!(websiteId && segmentId), + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteSegmentsQuery.ts b/src/components/hooks/queries/useWebsiteSegmentsQuery.ts new file mode 100644 index 0000000..8d3af96 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteSegmentsQuery.ts @@ -0,0 +1,24 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import { useFilterParameters } from '@/components/hooks/useFilterParameters'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useWebsiteSegmentsQuery( + websiteId: string, + params?: Record, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`segments`); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: ['website:segments', { websiteId, modified, ...filters, ...params }], + queryFn: pageParams => + get(`/websites/${websiteId}/segments`, { ...pageParams, ...filters, ...params }), + enabled: !!websiteId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteSessionQuery.ts b/src/components/hooks/queries/useWebsiteSessionQuery.ts new file mode 100644 index 0000000..21e9491 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteSessionQuery.ts @@ -0,0 +1,13 @@ +import { useApi } from '../useApi'; + +export function useWebsiteSessionQuery(websiteId: string, sessionId: string) { + const { get, useQuery } = useApi(); + + return useQuery({ + queryKey: ['session', { websiteId, sessionId }], + queryFn: () => { + return get(`/websites/${websiteId}/sessions/${sessionId}`); + }, + enabled: Boolean(websiteId && sessionId), + }); +} diff --git a/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts b/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts new file mode 100644 index 0000000..bac9fc9 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts @@ -0,0 +1,17 @@ +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useWebsiteSessionStatsQuery(websiteId: string, options?: Record) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: ['sessions:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }], + queryFn: () => + get(`/websites/${websiteId}/sessions/stats`, { startAt, endAt, unit, timezone, ...filters }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteSessionsQuery.ts b/src/components/hooks/queries/useWebsiteSessionsQuery.ts new file mode 100644 index 0000000..31906be --- /dev/null +++ b/src/components/hooks/queries/useWebsiteSessionsQuery.ts @@ -0,0 +1,34 @@ +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useWebsiteSessionsQuery( + websiteId: string, + params?: Record, +) { + const { get } = useApi(); + const { modified } = useModified(`sessions`); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return usePagedQuery({ + queryKey: [ + 'sessions', + { websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters }, + ], + queryFn: pageParams => { + return get(`/websites/${websiteId}/sessions`, { + startAt, + endAt, + unit, + timezone, + ...filters, + ...pageParams, + ...params, + pageSize: 20, + }); + }, + }); +} diff --git a/src/components/hooks/queries/useWebsiteStatsQuery.ts b/src/components/hooks/queries/useWebsiteStatsQuery.ts new file mode 100644 index 0000000..e9a0c48 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteStatsQuery.ts @@ -0,0 +1,36 @@ +import type { UseQueryOptions } from '@tanstack/react-query'; +import { useDateParameters } from '@/components/hooks/useDateParameters'; +import { useApi } from '../useApi'; +import { useFilterParameters } from '../useFilterParameters'; + +export interface WebsiteStatsData { + pageviews: number; + visitors: number; + visits: number; + bounces: number; + totaltime: number; + comparison: { + pageviews: number; + visitors: number; + visits: number; + bounces: number; + totaltime: number; + }; +} + +export function useWebsiteStatsQuery( + websiteId: string, + options?: UseQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: ['websites:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }], + queryFn: () => + get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, ...filters }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteValuesQuery.ts b/src/components/hooks/queries/useWebsiteValuesQuery.ts new file mode 100644 index 0000000..1e09736 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteValuesQuery.ts @@ -0,0 +1,62 @@ +import { useCountryNames } from '@/components/hooks/useCountryNames'; +import { useRegionNames } from '@/components/hooks/useRegionNames'; +import { useApi } from '../useApi'; +import { useLocale } from '../useLocale'; + +export function useWebsiteValuesQuery({ + websiteId, + type, + startDate, + endDate, + search, +}: { + websiteId: string; + type: string; + startDate: Date; + endDate: Date; + search?: string; +}) { + const { get, useQuery } = useApi(); + const { locale } = useLocale(); + const { countryNames } = useCountryNames(locale); + const { regionNames } = useRegionNames(locale); + + const names = { + country: countryNames, + region: regionNames, + }; + + const getSearch = (type: string, value: string) => { + if (value) { + const values = names[type]; + + if (values) { + return ( + Object.keys(values) + .reduce((arr: string[], key: string) => { + if (values[key].toLowerCase().includes(value.toLowerCase())) { + return arr.concat(key); + } + return arr; + }, []) + .slice(0, 5) + .join(',') || value + ); + } + + return value; + } + }; + + return useQuery({ + queryKey: ['websites:values', { websiteId, type, startDate, endDate, search }], + queryFn: () => + get(`/websites/${websiteId}/values`, { + type, + startAt: +startDate, + endAt: +endDate, + search: getSearch(type, search), + }), + enabled: !!(websiteId && type && startDate && endDate), + }); +} diff --git a/src/components/hooks/queries/useWebsitesQuery.ts b/src/components/hooks/queries/useWebsitesQuery.ts new file mode 100644 index 0000000..a7b6618 --- /dev/null +++ b/src/components/hooks/queries/useWebsitesQuery.ts @@ -0,0 +1,20 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useWebsitesQuery(params?: Record, options?: ReactQueryOptions) { + const { get } = useApi(); + const { modified } = useModified(`websites`); + + return usePagedQuery({ + queryKey: ['websites:admin', { modified, ...params }], + queryFn: pageParams => { + return get(`/admin/websites`, { + ...pageParams, + ...params, + }); + }, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWeeklyTrafficQuery.ts b/src/components/hooks/queries/useWeeklyTrafficQuery.ts new file mode 100644 index 0000000..a76ebb3 --- /dev/null +++ b/src/components/hooks/queries/useWeeklyTrafficQuery.ts @@ -0,0 +1,28 @@ +import { useFilterParameters } from '@/components/hooks/useFilterParameters'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useModified } from '../useModified'; + +export function useWeeklyTrafficQuery(websiteId: string, params?: Record) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`sessions`); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: [ + 'sessions', + { websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters }, + ], + queryFn: () => { + return get(`/websites/${websiteId}/sessions/weekly`, { + startAt, + endAt, + unit, + timezone, + ...params, + ...filters, + }); + }, + }); +} diff --git a/src/components/hooks/useApi.ts b/src/components/hooks/useApi.ts new file mode 100644 index 0000000..35cabd5 --- /dev/null +++ b/src/components/hooks/useApi.ts @@ -0,0 +1,67 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { getClientAuthToken } from '@/lib/client'; +import { SHARE_TOKEN_HEADER } from '@/lib/constants'; +import { type FetchResponse, httpDelete, httpGet, httpPost, httpPut } from '@/lib/fetch'; +import { useApp } from '@/store/app'; + +const selector = (state: { shareToken: { token?: string } }) => state.shareToken; + +async function handleResponse(res: FetchResponse): Promise { + if (!res.ok) { + const { message, code, status } = res?.data?.error || {}; + + return Promise.reject(Object.assign(new Error(message), { code, status })); + } + return Promise.resolve(res.data); +} + +export function useApi() { + const shareToken = useApp(selector); + + const defaultHeaders = { + authorization: `Bearer ${getClientAuthToken()}`, + [SHARE_TOKEN_HEADER]: shareToken?.token, + }; + const basePath = process.env.basePath; + + const getUrl = (url: string) => { + return url.startsWith('http') ? url : `${basePath || ''}/api${url}`; + }; + + const getHeaders = (headers: any = {}) => { + return { ...defaultHeaders, ...headers }; + }; + + return { + get: useCallback( + async (url: string, params: object = {}, headers: object = {}) => { + return httpGet(getUrl(url), params, getHeaders(headers)).then(handleResponse); + }, + [httpGet], + ), + + post: useCallback( + async (url: string, params: object = {}, headers: object = {}) => { + return httpPost(getUrl(url), params, getHeaders(headers)).then(handleResponse); + }, + [httpPost], + ), + + put: useCallback( + async (url: string, params: object = {}, headers: object = {}) => { + return httpPut(getUrl(url), params, getHeaders(headers)).then(handleResponse); + }, + [httpPut], + ), + + del: useCallback( + async (url: string, params: object = {}, headers: object = {}) => { + return httpDelete(getUrl(url), params, getHeaders(headers)).then(handleResponse); + }, + [httpDelete], + ), + useQuery, + useMutation, + }; +} diff --git a/src/components/hooks/useConfig.ts b/src/components/hooks/useConfig.ts new file mode 100644 index 0000000..c1cdcaf --- /dev/null +++ b/src/components/hooks/useConfig.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { useApi } from '@/components/hooks/useApi'; +import { setConfig, useApp } from '@/store/app'; + +export type Config = { + cloudMode: boolean; + faviconUrl?: string; + linksUrl?: string; + pixelsUrl?: string; + privateMode: boolean; + telemetryDisabled: boolean; + trackerScriptName?: string; + updatesDisabled: boolean; +}; + +export function useConfig(): Config { + const { config } = useApp(); + const { get } = useApi(); + + async function loadConfig() { + const data = await get(`/config`); + + setConfig(data); + } + + useEffect(() => { + if (!config) { + loadConfig(); + } + }, []); + + return config; +} diff --git a/src/components/hooks/useCountryNames.ts b/src/components/hooks/useCountryNames.ts new file mode 100644 index 0000000..1ec9fc1 --- /dev/null +++ b/src/components/hooks/useCountryNames.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; +import { httpGet } from '@/lib/fetch'; +import enUS from '../../../public/intl/country/en-US.json'; + +const countryNames = { + 'en-US': enUS, +}; + +export function useCountryNames(locale: string) { + const [list, setList] = useState(countryNames[locale] || enUS); + + async function loadData(locale: string) { + const { data } = await httpGet(`${process.env.basePath || ''}/intl/country/${locale}.json`); + + if (data) { + countryNames[locale] = data; + setList(countryNames[locale]); + } else { + setList(enUS); + } + } + + useEffect(() => { + if (!countryNames[locale]) { + loadData(locale); + } else { + setList(countryNames[locale]); + } + }, [locale]); + + return { countryNames: list }; +} diff --git a/src/components/hooks/useDateParameters.ts b/src/components/hooks/useDateParameters.ts new file mode 100644 index 0000000..d84b423 --- /dev/null +++ b/src/components/hooks/useDateParameters.ts @@ -0,0 +1,18 @@ +import { useDateRange } from './useDateRange'; +import { useTimezone } from './useTimezone'; + +export function useDateParameters() { + const { + dateRange: { startDate, endDate, unit }, + } = useDateRange(); + const { timezone, localToUtc, canonicalizeTimezone } = useTimezone(); + + return { + startAt: +localToUtc(startDate), + endAt: +localToUtc(endDate), + startDate: localToUtc(startDate).toISOString(), + endDate: localToUtc(endDate).toISOString(), + unit, + timezone: canonicalizeTimezone(timezone), + }; +} diff --git a/src/components/hooks/useDateRange.ts b/src/components/hooks/useDateRange.ts new file mode 100644 index 0000000..755f36e --- /dev/null +++ b/src/components/hooks/useDateRange.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; +import { useLocale } from '@/components/hooks/useLocale'; +import { useNavigation } from '@/components/hooks/useNavigation'; +import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants'; +import { getCompareDate, getOffsetDateRange, parseDateRange } from '@/lib/date'; +import { getItem } from '@/lib/storage'; + +export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) { + const { + query: { date = '', offset = 0, compare = 'prev' }, + } = useNavigation(); + const { locale } = useLocale(); + + const dateRange = useMemo(() => { + const dateRangeObject = parseDateRange( + date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE, + locale, + options.timezone, + ); + + return !options.ignoreOffset && offset + ? getOffsetDateRange(dateRangeObject, +offset) + : dateRangeObject; + }, [date, offset, options]); + + const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate); + + return { + date, + offset, + compare, + isAllTime: date.endsWith(`:all`), + isCustomRange: date.startsWith('range:'), + dateRange, + dateCompare, + }; +} diff --git a/src/components/hooks/useDocumentClick.ts b/src/components/hooks/useDocumentClick.ts new file mode 100644 index 0000000..611f628 --- /dev/null +++ b/src/components/hooks/useDocumentClick.ts @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; + +export function useDocumentClick(handler: (event: MouseEvent) => any) { + useEffect(() => { + document.addEventListener('click', handler); + + return () => { + document.removeEventListener('click', handler); + }; + }, [handler]); + + return null; +} diff --git a/src/components/hooks/useEscapeKey.ts b/src/components/hooks/useEscapeKey.ts new file mode 100644 index 0000000..cc1d308 --- /dev/null +++ b/src/components/hooks/useEscapeKey.ts @@ -0,0 +1,19 @@ +import { type KeyboardEvent, useCallback, useEffect } from 'react'; + +export function useEscapeKey(handler: (event: KeyboardEvent) => void) { + const escFunction = useCallback((event: KeyboardEvent) => { + if (event.key === 'Escape') { + handler(event); + } + }, []); + + useEffect(() => { + document.addEventListener('keydown', escFunction as any, false); + + return () => { + document.removeEventListener('keydown', escFunction as any, false); + }; + }, [escFunction]); + + return null; +} diff --git a/src/components/hooks/useFields.ts b/src/components/hooks/useFields.ts new file mode 100644 index 0000000..22a1dcf --- /dev/null +++ b/src/components/hooks/useFields.ts @@ -0,0 +1,23 @@ +import { useMessages } from './useMessages'; + +export function useFields() { + const { formatMessage, labels } = useMessages(); + + const fields = [ + { name: 'path', type: 'string', label: formatMessage(labels.path) }, + { name: 'query', type: 'string', label: formatMessage(labels.query) }, + { name: 'title', type: 'string', label: formatMessage(labels.pageTitle) }, + { name: 'referrer', type: 'string', label: formatMessage(labels.referrer) }, + { name: 'browser', type: 'string', label: formatMessage(labels.browser) }, + { name: 'os', type: 'string', label: formatMessage(labels.os) }, + { name: 'device', type: 'string', label: formatMessage(labels.device) }, + { name: 'country', type: 'string', label: formatMessage(labels.country) }, + { name: 'region', type: 'string', label: formatMessage(labels.region) }, + { name: 'city', type: 'string', label: formatMessage(labels.city) }, + { name: 'hostname', type: 'string', label: formatMessage(labels.hostname) }, + { name: 'tag', type: 'string', label: formatMessage(labels.tag) }, + { name: 'event', type: 'string', label: formatMessage(labels.event) }, + ]; + + return { fields }; +} diff --git a/src/components/hooks/useFilterParameters.ts b/src/components/hooks/useFilterParameters.ts new file mode 100644 index 0000000..5403212 --- /dev/null +++ b/src/components/hooks/useFilterParameters.ts @@ -0,0 +1,70 @@ +import { useMemo } from 'react'; +import { useNavigation } from './useNavigation'; + +export function useFilterParameters() { + const { + query: { + path, + referrer, + title, + query, + host, + os, + browser, + device, + country, + region, + city, + event, + tag, + hostname, + page, + pageSize, + search, + segment, + cohort, + }, + } = useNavigation(); + + return useMemo(() => { + return { + path, + referrer, + title, + query, + host, + os, + browser, + device, + country, + region, + city, + event, + tag, + hostname, + search, + segment, + cohort, + }; + }, [ + path, + referrer, + title, + query, + host, + os, + browser, + device, + country, + region, + city, + event, + tag, + hostname, + page, + pageSize, + search, + segment, + cohort, + ]); +} diff --git a/src/components/hooks/useFilters.ts b/src/components/hooks/useFilters.ts new file mode 100644 index 0000000..850e2af --- /dev/null +++ b/src/components/hooks/useFilters.ts @@ -0,0 +1,99 @@ +import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants'; +import { safeDecodeURIComponent } from '@/lib/url'; +import { useFields } from './useFields'; +import { useMessages } from './useMessages'; +import { useNavigation } from './useNavigation'; + +export function useFilters() { + const { formatMessage, labels } = useMessages(); + const { query } = useNavigation(); + const { fields } = useFields(); + + const operators = [ + { name: 'eq', type: 'string', label: formatMessage(labels.is) }, + { name: 'neq', type: 'string', label: formatMessage(labels.isNot) }, + { name: 'c', type: 'string', label: formatMessage(labels.contains) }, + { name: 'dnc', type: 'string', label: formatMessage(labels.doesNotContain) }, + { name: 'i', type: 'array', label: formatMessage(labels.includes) }, + { name: 'dni', type: 'array', label: formatMessage(labels.doesNotInclude) }, + { name: 't', type: 'boolean', label: formatMessage(labels.isTrue) }, + { name: 'f', type: 'boolean', label: formatMessage(labels.isFalse) }, + { name: 'eq', type: 'number', label: formatMessage(labels.is) }, + { name: 'neq', type: 'number', label: formatMessage(labels.isNot) }, + { name: 'gt', type: 'number', label: formatMessage(labels.greaterThan) }, + { name: 'lt', type: 'number', label: formatMessage(labels.lessThan) }, + { name: 'gte', type: 'number', label: formatMessage(labels.greaterThanEquals) }, + { name: 'lte', type: 'number', label: formatMessage(labels.lessThanEquals) }, + { name: 'bf', type: 'date', label: formatMessage(labels.before) }, + { name: 'af', type: 'date', label: formatMessage(labels.after) }, + { name: 'eq', type: 'uuid', label: formatMessage(labels.is) }, + ]; + + const operatorLabels = { + [OPERATORS.equals]: formatMessage(labels.is), + [OPERATORS.notEquals]: formatMessage(labels.isNot), + [OPERATORS.set]: formatMessage(labels.isSet), + [OPERATORS.notSet]: formatMessage(labels.isNotSet), + [OPERATORS.contains]: formatMessage(labels.contains), + [OPERATORS.doesNotContain]: formatMessage(labels.doesNotContain), + [OPERATORS.true]: formatMessage(labels.true), + [OPERATORS.false]: formatMessage(labels.false), + [OPERATORS.greaterThan]: formatMessage(labels.greaterThan), + [OPERATORS.lessThan]: formatMessage(labels.lessThan), + [OPERATORS.greaterThanEquals]: formatMessage(labels.greaterThanEquals), + [OPERATORS.lessThanEquals]: formatMessage(labels.lessThanEquals), + [OPERATORS.before]: formatMessage(labels.before), + [OPERATORS.after]: formatMessage(labels.after), + }; + + const typeFilters = { + string: [OPERATORS.equals, OPERATORS.notEquals, OPERATORS.contains, OPERATORS.doesNotContain], + array: [OPERATORS.contains, OPERATORS.doesNotContain], + boolean: [OPERATORS.true, OPERATORS.false], + number: [ + OPERATORS.equals, + OPERATORS.notEquals, + OPERATORS.greaterThan, + OPERATORS.lessThan, + OPERATORS.greaterThanEquals, + OPERATORS.lessThanEquals, + ], + date: [OPERATORS.before, OPERATORS.after], + uuid: [OPERATORS.equals], + }; + + const filters = Object.keys(query).reduce((arr, key) => { + if (FILTER_COLUMNS[key]) { + let operator = 'eq'; + let value = safeDecodeURIComponent(query[key]); + const label = fields.find(({ name }) => name === key)?.label; + + const match = value.match(/^([a-z]+)\.(.*)/); + + if (match) { + operator = match[1]; + value = match[2]; + } + + return arr.concat({ + name: key, + operator, + value, + label, + }); + } + return arr; + }, []); + + const getFilters = (type: string) => { + return ( + typeFilters[type]?.map((key: string | number) => ({ + type, + value: key, + label: operatorLabels[key], + })) ?? [] + ); + }; + + return { fields, operators, filters, operatorLabels, typeFilters, getFilters }; +} diff --git a/src/components/hooks/useForceUpdate.ts b/src/components/hooks/useForceUpdate.ts new file mode 100644 index 0000000..550cc5c --- /dev/null +++ b/src/components/hooks/useForceUpdate.ts @@ -0,0 +1,9 @@ +import { useCallback, useState } from 'react'; + +export function useForceUpdate() { + const [, update] = useState(Object.create(null)); + + return useCallback(() => { + update(Object.create(null)); + }, [update]); +} diff --git a/src/components/hooks/useFormat.ts b/src/components/hooks/useFormat.ts new file mode 100644 index 0000000..896fa07 --- /dev/null +++ b/src/components/hooks/useFormat.ts @@ -0,0 +1,74 @@ +import { BROWSERS, OS_NAMES } from '@/lib/constants'; +import regions from '../../../public/iso-3166-2.json'; +import { useCountryNames } from './useCountryNames'; +import { useLanguageNames } from './useLanguageNames'; +import { useLocale } from './useLocale'; +import { useMessages } from './useMessages'; + +export function useFormat() { + const { formatMessage, labels } = useMessages(); + const { locale } = useLocale(); + const { countryNames } = useCountryNames(locale); + const { languageNames } = useLanguageNames(locale); + + const formatOS = (value: string): string => { + return OS_NAMES[value] || value; + }; + + const formatBrowser = (value: string): string => { + return BROWSERS[value] || value; + }; + + const formatDevice = (value: string): string => { + return formatMessage(labels[value] || labels.unknown); + }; + + const formatCountry = (value: string): string => { + return countryNames[value] || value; + }; + + const formatRegion = (value?: string): string => { + const [country] = value?.split('-') || []; + return regions[value] ? `${regions[value]}, ${countryNames[country]}` : value; + }; + + const formatCity = (value: string, country?: string): string => { + return countryNames[country] ? `${value}, ${countryNames[country]}` : value; + }; + + const formatLanguage = (value: string): string => { + return languageNames[value?.split('-')[0]] || value; + }; + + const formatValue = (value: string, type: string, data?: Record): string => { + switch (type) { + case 'os': + return formatOS(value); + case 'browser': + return formatBrowser(value); + case 'device': + return formatDevice(value); + case 'country': + return formatCountry(value); + case 'region': + return formatRegion(value); + case 'city': + return formatCity(value, data?.country); + case 'language': + return formatLanguage(value); + default: + return typeof value === 'string' ? value : undefined; + } + }; + + return { + formatOS, + formatBrowser, + formatDevice, + formatCountry, + formatRegion, + formatCity, + formatLanguage, + formatValue, + }; +} diff --git a/src/components/hooks/useGlobalState.ts b/src/components/hooks/useGlobalState.ts new file mode 100644 index 0000000..6f21226 --- /dev/null +++ b/src/components/hooks/useGlobalState.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +const store = create(() => ({})); + +const useGlobalState = (key: string, value?: any) => { + if (value !== undefined && store.getState()[key] === undefined) { + store.setState({ [key]: value }); + } + + return [store(state => state[key]), (value: any) => store.setState({ [key]: value })]; +}; + +export { useGlobalState }; diff --git a/src/components/hooks/useLanguageNames.ts b/src/components/hooks/useLanguageNames.ts new file mode 100644 index 0000000..0cc03d7 --- /dev/null +++ b/src/components/hooks/useLanguageNames.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; +import { httpGet } from '@/lib/fetch'; +import enUS from '../../../public/intl/language/en-US.json'; + +const languageNames = { + 'en-US': enUS, +}; + +export function useLanguageNames(locale) { + const [list, setList] = useState(languageNames[locale] || enUS); + + async function loadData(locale) { + const { data } = await httpGet(`${process.env.basePath || ''}/intl/language/${locale}.json`); + + if (data) { + languageNames[locale] = data; + setList(languageNames[locale]); + } else { + setList(enUS); + } + } + + useEffect(() => { + if (!languageNames[locale]) { + loadData(locale); + } else { + setList(languageNames[locale]); + } + }, [locale]); + + return { languageNames: list }; +} diff --git a/src/components/hooks/useLocale.ts b/src/components/hooks/useLocale.ts new file mode 100644 index 0000000..3eb669e --- /dev/null +++ b/src/components/hooks/useLocale.ts @@ -0,0 +1,60 @@ +import { useEffect } from 'react'; +import { LOCALE_CONFIG } from '@/lib/constants'; +import { httpGet } from '@/lib/fetch'; +import { getDateLocale, getTextDirection } from '@/lib/lang'; +import { setItem } from '@/lib/storage'; +import { setLocale, useApp } from '@/store/app'; +import enUS from '../../../public/intl/country/en-US.json'; +import { useForceUpdate } from './useForceUpdate'; + +const messages = { + 'en-US': enUS, +}; + +const selector = (state: { locale: string }) => state.locale; + +export function useLocale() { + const locale = useApp(selector); + const forceUpdate = useForceUpdate(); + const dir = getTextDirection(locale); + const dateLocale = getDateLocale(locale); + + async function loadMessages(locale: string) { + const { data } = await httpGet(`${process.env.basePath || ''}/intl/messages/${locale}.json`); + + messages[locale] = data; + } + + async function saveLocale(value: string) { + if (!messages[value]) { + await loadMessages(value); + } + + setItem(LOCALE_CONFIG, value); + + document.getElementById('__next')?.setAttribute('dir', getTextDirection(value)); + + if (locale !== value) { + setLocale(value); + } else { + forceUpdate(); + } + } + + useEffect(() => { + if (!messages[locale]) { + saveLocale(locale); + } + }, [locale]); + + useEffect(() => { + const url = new URL(window?.location?.href); + const locale = url.searchParams.get('locale'); + + if (locale) { + saveLocale(locale); + } + }, []); + + return { locale, saveLocale, messages, dir, dateLocale }; +} diff --git a/src/components/hooks/useMessages.ts b/src/components/hooks/useMessages.ts new file mode 100644 index 0000000..d5bc242 --- /dev/null +++ b/src/components/hooks/useMessages.ts @@ -0,0 +1,48 @@ +import { FormattedMessage, type MessageDescriptor, useIntl } from 'react-intl'; +import { labels, messages } from '@/components/messages'; +import type { ApiError } from '@/lib/types'; + +type FormatMessage = ( + descriptor: MessageDescriptor, + values?: Record, + opts?: any, +) => string | null; + +interface UseMessages { + formatMessage: FormatMessage; + messages: typeof messages; + labels: typeof labels; + getMessage: (id: string) => string; + getErrorMessage: (error: ApiError) => string | undefined; + FormattedMessage: typeof FormattedMessage; +} + +export function useMessages(): UseMessages { + const intl = useIntl(); + + const getMessage = (id: string) => { + const message = Object.values(messages).find(value => value.id === `message.${id}`); + + return message ? formatMessage(message) : id; + }; + + const getErrorMessage = (error: ApiError) => { + if (!error) { + return undefined; + } + + const code = error?.code; + + return code ? getMessage(code) : error?.message || 'Unknown error'; + }; + + const formatMessage = ( + descriptor: MessageDescriptor, + values?: Record, + opts?: any, + ) => { + return descriptor ? intl.formatMessage(descriptor, values, opts) : null; + }; + + return { formatMessage, messages, labels, getMessage, getErrorMessage, FormattedMessage }; +} diff --git a/src/components/hooks/useMobile.ts b/src/components/hooks/useMobile.ts new file mode 100644 index 0000000..6b40f3d --- /dev/null +++ b/src/components/hooks/useMobile.ts @@ -0,0 +1,9 @@ +import { useBreakpoint } from '@umami/react-zen'; + +export function useMobile() { + const breakpoint = useBreakpoint(); + const isMobile = ['xs', 'sm', 'md'].includes(breakpoint); + const isPhone = ['xs', 'sm'].includes(breakpoint); + + return { breakpoint, isMobile, isPhone }; +} diff --git a/src/components/hooks/useModified.ts b/src/components/hooks/useModified.ts new file mode 100644 index 0000000..ea88888 --- /dev/null +++ b/src/components/hooks/useModified.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +const store = create(() => ({})); + +export function touch(key: string) { + store.setState({ [key]: Date.now() }); +} + +export function useModified(key?: string) { + const modified = store(state => state?.[key]); + + return { modified, touch }; +} diff --git a/src/components/hooks/useNavigation.ts b/src/components/hooks/useNavigation.ts new file mode 100644 index 0000000..0a18ac7 --- /dev/null +++ b/src/components/hooks/useNavigation.ts @@ -0,0 +1,43 @@ +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { buildPath } from '@/lib/url'; + +export function useNavigation() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [, teamId] = pathname.match(/\/teams\/([a-f0-9-]+)/) || []; + const [, websiteId] = pathname.match(/\/websites\/([a-f0-9-]+)/) || []; + const [queryParams, setQueryParams] = useState(Object.fromEntries(searchParams)); + + const updateParams = (params?: Record) => { + return buildPath(pathname, { ...queryParams, ...params }); + }; + + const replaceParams = (params?: Record) => { + return buildPath(pathname, params); + }; + + const renderUrl = (path: string, params?: Record | false) => { + return buildPath( + teamId ? `/teams/${teamId}${path}` : path, + params === false ? {} : { ...queryParams, ...params }, + ); + }; + + useEffect(() => { + setQueryParams(Object.fromEntries(searchParams)); + }, [searchParams.toString()]); + + return { + router, + pathname, + searchParams, + query: queryParams, + teamId, + websiteId, + updateParams, + replaceParams, + renderUrl, + }; +} diff --git a/src/components/hooks/usePageParameters.ts b/src/components/hooks/usePageParameters.ts new file mode 100644 index 0000000..42cf391 --- /dev/null +++ b/src/components/hooks/usePageParameters.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; +import { useNavigation } from './useNavigation'; + +export function usePageParameters() { + const { + query: { page, pageSize, search }, + } = useNavigation(); + + return useMemo(() => { + return { + page, + pageSize, + search, + }; + }, [page, pageSize, search]); +} diff --git a/src/components/hooks/usePagedQuery.ts b/src/components/hooks/usePagedQuery.ts new file mode 100644 index 0000000..c818de6 --- /dev/null +++ b/src/components/hooks/usePagedQuery.ts @@ -0,0 +1,27 @@ +import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import type { PageResult } from '@/lib/types'; +import { useApi } from './useApi'; +import { useNavigation } from './useNavigation'; + +export function usePagedQuery({ + queryKey, + queryFn, + ...options +}: Omit< + UseQueryOptions, TError, PageResult, readonly unknown[]>, + 'queryFn' | 'queryKey' +> & { + queryKey: readonly unknown[]; + queryFn: (params?: object) => Promise> | PageResult; +}): UseQueryResult, TError> { + const { + query: { page, search }, + } = useNavigation(); + const { useQuery } = useApi(); + + return useQuery, TError>({ + queryKey: [...queryKey, page, search] as const, + queryFn: () => queryFn({ page, search }), + ...options, + }); +} diff --git a/src/components/hooks/useRegionNames.ts b/src/components/hooks/useRegionNames.ts new file mode 100644 index 0000000..57dcc41 --- /dev/null +++ b/src/components/hooks/useRegionNames.ts @@ -0,0 +1,22 @@ +import regions from '../../../public/iso-3166-2.json'; +import { useCountryNames } from './useCountryNames'; + +export function useRegionNames(locale: string) { + const { countryNames } = useCountryNames(locale); + + const getRegionName = (regionCode: string, countryCode?: string) => { + if (!countryCode) { + return regions[regionCode]; + } + + if (!regionCode) { + return null; + } + + const region = regionCode?.includes('-') ? regionCode : `${countryCode}-${regionCode}`; + + return regions[region] ? `${regions[region]}, ${countryNames[countryCode]}` : region; + }; + + return { regionNames: regions, getRegionName }; +} diff --git a/src/components/hooks/useSlug.ts b/src/components/hooks/useSlug.ts new file mode 100644 index 0000000..f795dfe --- /dev/null +++ b/src/components/hooks/useSlug.ts @@ -0,0 +1,14 @@ +import { useConfig } from '@/components/hooks/useConfig'; +import { LINKS_URL, PIXELS_URL } from '@/lib/constants'; + +export function useSlug(type: 'link' | 'pixel') { + const { linksUrl, pixelsUrl } = useConfig(); + + const hostUrl = type === 'link' ? linksUrl || LINKS_URL : pixelsUrl || PIXELS_URL; + + const getSlugUrl = (slug: string) => { + return `${hostUrl}/${slug}`; + }; + + return { getSlugUrl, hostUrl }; +} diff --git a/src/components/hooks/useSticky.ts b/src/components/hooks/useSticky.ts new file mode 100644 index 0000000..ef9fb36 --- /dev/null +++ b/src/components/hooks/useSticky.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef, useState } from 'react'; + +export function useSticky({ enabled = true, threshold = 1 }) { + const [isSticky, setIsSticky] = useState(false); + const ref = useRef(null); + + useEffect(() => { + let observer: IntersectionObserver | undefined; + // eslint-disable-next-line no-undef + const handler: IntersectionObserverCallback = ([entry]) => + setIsSticky(entry.intersectionRatio < threshold); + + if (enabled && ref.current) { + observer = new IntersectionObserver(handler, { threshold: [threshold] }); + observer.observe(ref.current); + } + return () => { + if (observer) { + observer.disconnect(); + } + }; + }, [ref, enabled, threshold]); + + return { ref, isSticky }; +} diff --git a/src/components/hooks/useTimezone.ts b/src/components/hooks/useTimezone.ts new file mode 100644 index 0000000..ef25539 --- /dev/null +++ b/src/components/hooks/useTimezone.ts @@ -0,0 +1,95 @@ +import { formatInTimeZone, utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz'; +import { TIMEZONE_CONFIG, TIMEZONE_LEGACY } from '@/lib/constants'; +import { getTimezone } from '@/lib/date'; +import { setItem } from '@/lib/storage'; +import { setTimezone, useApp } from '@/store/app'; +import { useLocale } from './useLocale'; + +const selector = (state: { timezone: string }) => state.timezone; + +export function useTimezone() { + const timezone = useApp(selector); + const localTimeZone = getTimezone(); + const { dateLocale } = useLocale(); + + const saveTimezone = (value: string) => { + setItem(TIMEZONE_CONFIG, value); + setTimezone(value); + }; + + const formatTimezoneDate = (date: string, pattern: string) => { + return formatInTimeZone( + /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{3})?Z$/.test(date) + ? date + : `${date.split(' ').join('T')}Z`, + timezone, + pattern, + { locale: dateLocale }, + ); + }; + + const formatSeriesTimezone = (data: any, column: string, timezone: string) => { + return data.map(item => { + const date = new Date(item[column]); + + const format = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + + const parts = format.formatToParts(date); + const get = type => parts.find(p => p.type === type)?.value; + + const year = get('year'); + const month = get('month'); + const day = get('day'); + const hour = get('hour'); + const minute = get('minute'); + const second = get('second'); + + return { + ...item, + [column]: `${year}-${month}-${day} ${hour}:${minute}:${second}`, + }; + }); + }; + + const toUtc = (date: Date | string | number) => { + return zonedTimeToUtc(date, timezone); + }; + + const fromUtc = (date: Date | string | number) => { + return utcToZonedTime(date, timezone); + }; + + const localToUtc = (date: Date | string | number) => { + return zonedTimeToUtc(date, localTimeZone); + }; + + const localFromUtc = (date: Date | string | number) => { + return utcToZonedTime(date, localTimeZone); + }; + + const canonicalizeTimezone = (timezone: string): string => { + return TIMEZONE_LEGACY[timezone] ?? timezone; + }; + + return { + timezone, + localTimeZone, + toUtc, + fromUtc, + localToUtc, + localFromUtc, + saveTimezone, + formatTimezoneDate, + formatSeriesTimezone, + canonicalizeTimezone, + }; +} diff --git a/src/components/icons.ts b/src/components/icons.ts new file mode 100644 index 0000000..fe433d5 --- /dev/null +++ b/src/components/icons.ts @@ -0,0 +1 @@ +export * from 'lucide-react'; diff --git a/src/components/input/ActionSelect.tsx b/src/components/input/ActionSelect.tsx new file mode 100644 index 0000000..616ee34 --- /dev/null +++ b/src/components/input/ActionSelect.tsx @@ -0,0 +1,18 @@ +import { ListItem, Select } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; + +export interface ActionSelectProps { + value?: string; + onChange?: (value: string) => void; +} + +export function ActionSelect({ value = 'path', onChange }: ActionSelectProps) { + const { formatMessage, labels } = useMessages(); + + return ( + + ); +} diff --git a/src/components/input/CurrencySelect.tsx b/src/components/input/CurrencySelect.tsx new file mode 100644 index 0000000..2b6045b --- /dev/null +++ b/src/components/input/CurrencySelect.tsx @@ -0,0 +1,34 @@ +import { ListItem, Select } from '@umami/react-zen'; +import { useState } from 'react'; +import { useMessages } from '@/components/hooks'; +import { CURRENCIES } from '@/lib/constants'; + +export function CurrencySelect({ value, onChange }) { + const { formatMessage, labels } = useMessages(); + const [search, setSearch] = useState(''); + + return ( + + ); +} diff --git a/src/components/input/DateFilter.tsx b/src/components/input/DateFilter.tsx new file mode 100644 index 0000000..2e17529 --- /dev/null +++ b/src/components/input/DateFilter.tsx @@ -0,0 +1,141 @@ +import { Dialog, ListItem, ListSeparator, Modal, Select, type SelectProps } from '@umami/react-zen'; +import { endOfYear } from 'date-fns'; +import { Fragment, type Key, useState } from 'react'; +import { DateDisplay } from '@/components/common/DateDisplay'; +import { useMessages, useMobile } from '@/components/hooks'; +import { DatePickerForm } from '@/components/metrics/DatePickerForm'; +import { parseDateRange } from '@/lib/date'; + +export interface DateFilterProps extends SelectProps { + value?: string; + onChange?: (value: string) => void; + showAllTime?: boolean; + renderDate?: boolean; + placement?: any; +} + +export function DateFilter({ + value, + onChange, + showAllTime, + renderDate, + placement = 'bottom', + ...props +}: DateFilterProps) { + const { formatMessage, labels } = useMessages(); + const [showPicker, setShowPicker] = useState(false); + const { startDate, endDate } = parseDateRange(value) || {}; + const { isMobile } = useMobile(); + + const options = [ + { label: formatMessage(labels.today), value: '0day' }, + { + label: formatMessage(labels.lastHours, { x: '24' }), + value: '24hour', + }, + { + label: formatMessage(labels.thisWeek), + value: '0week', + divider: true, + }, + { + label: formatMessage(labels.lastDays, { x: '7' }), + value: '7day', + }, + { + label: formatMessage(labels.thisMonth), + value: '0month', + divider: true, + }, + { + label: formatMessage(labels.lastDays, { x: '30' }), + value: '30day', + }, + { + label: formatMessage(labels.lastDays, { x: '90' }), + value: '90day', + }, + { label: formatMessage(labels.thisYear), value: '0year' }, + { + label: formatMessage(labels.lastMonths, { x: '6' }), + value: '6month', + divider: true, + }, + { + label: formatMessage(labels.lastMonths, { x: '12' }), + value: '12month', + }, + showAllTime && { + label: formatMessage(labels.allTime), + value: 'all', + divider: true, + }, + { + label: formatMessage(labels.customRange), + value: 'custom', + divider: true, + }, + ] + .filter(n => n) + .map((a, id) => ({ ...a, id })); + + const handleChange = (value: Key) => { + if (value === 'custom') { + setShowPicker(true); + return; + } + onChange(value.toString()); + }; + + const handlePickerChange = (value: string) => { + setShowPicker(false); + onChange(value.toString()); + }; + + const renderValue = ({ defaultChildren }) => { + return value?.startsWith('range') || renderDate ? ( + + ) : ( + defaultChildren + ); + }; + + const selectedValue = value.endsWith(':all') ? 'all' : value; + + return ( + <> + + {showPicker && ( + + + setShowPicker(false)} + /> + + + )} + + ); +} diff --git a/src/components/input/DialogButton.tsx b/src/components/input/DialogButton.tsx new file mode 100644 index 0000000..7527226 --- /dev/null +++ b/src/components/input/DialogButton.tsx @@ -0,0 +1,64 @@ +import { + Button, + type ButtonProps, + Dialog, + type DialogProps, + DialogTrigger, + IconLabel, + Modal, +} from '@umami/react-zen'; +import type { CSSProperties, ReactNode } from 'react'; +import { useMobile } from '@/components/hooks'; + +export interface DialogButtonProps extends Omit { + icon?: ReactNode; + label?: ReactNode; + title?: ReactNode; + width?: string; + height?: string; + minWidth?: string; + minHeight?: string; + children?: DialogProps['children']; +} + +export function DialogButton({ + icon, + label, + title, + width, + height, + minWidth, + minHeight, + children, + ...props +}: DialogButtonProps) { + const { isMobile } = useMobile(); + const style: CSSProperties = { + width, + height, + minWidth, + minHeight, + maxHeight: 'calc(100dvh - 40px)', + padding: '32px', + }; + + if (isMobile) { + style.width = '100%'; + style.height = '100%'; + style.maxHeight = '100%'; + style.overflowY = 'auto'; + } + + return ( + + + + + {children} + + + + ); +} diff --git a/src/components/input/DownloadButton.tsx b/src/components/input/DownloadButton.tsx new file mode 100644 index 0000000..5df3305 --- /dev/null +++ b/src/components/input/DownloadButton.tsx @@ -0,0 +1,42 @@ +import { Button, Icon, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import Papa from 'papaparse'; +import { useMessages } from '@/components/hooks'; +import { Download } from '@/components/icons'; + +export function DownloadButton({ + filename = 'data', + data, +}: { + filename?: string; + data?: any; + onClick?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + + const handleClick = async () => { + downloadCsv(`${filename}.csv`, Papa.unparse(data)); + }; + + return ( + + + {formatMessage(labels.download)} + + ); +} + +function downloadCsv(filename: string, data: any) { + const blob = new Blob([data], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + + URL.revokeObjectURL(url); +} diff --git a/src/components/input/ExportButton.tsx b/src/components/input/ExportButton.tsx new file mode 100644 index 0000000..7b65a57 --- /dev/null +++ b/src/components/input/ExportButton.tsx @@ -0,0 +1,64 @@ +import { Icon, LoadingButton, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import { useSearchParams } from 'next/navigation'; +import { useState } from 'react'; +import { useApi, useMessages } from '@/components/hooks'; +import { useDateParameters } from '@/components/hooks/useDateParameters'; +import { useFilterParameters } from '@/components/hooks/useFilterParameters'; +import { Download } from '@/components/icons'; + +export function ExportButton({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + const [isLoading, setIsLoading] = useState(false); + const date = useDateParameters(); + const filters = useFilterParameters(); + const searchParams = useSearchParams(); + const { get } = useApi(); + + const handleClick = async () => { + setIsLoading(true); + + const { zip } = await get(`/websites/${websiteId}/export`, { + ...date, + ...filters, + ...searchParams, + format: 'json', + }); + + await loadZip(zip); + + setIsLoading(false); + }; + + return ( + + + + + + + {formatMessage(labels.download)} + + ); +} + +async function loadZip(zip: string) { + const binary = atob(zip); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + const blob = new Blob([bytes], { type: 'application/zip' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = 'download.zip'; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/components/input/FieldFilters.tsx b/src/components/input/FieldFilters.tsx new file mode 100644 index 0000000..2174068 --- /dev/null +++ b/src/components/input/FieldFilters.tsx @@ -0,0 +1,117 @@ +import { + Button, + Column, + Grid, + Icon, + List, + ListItem, + Menu, + MenuItem, + MenuTrigger, + Popover, + Row, +} from '@umami/react-zen'; +import { endOfDay, subMonths } from 'date-fns'; +import type { Key } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { FilterRecord } from '@/components/common/FilterRecord'; +import { useFields, useMessages, useMobile } from '@/components/hooks'; +import { Plus } from '@/components/icons'; + +export interface FieldFiltersProps { + websiteId: string; + value?: { name: string; operator: string; value: string }[]; + exclude?: string[]; + onChange?: (data: any) => void; +} + +export function FieldFilters({ websiteId, value, exclude = [], onChange }: FieldFiltersProps) { + const { formatMessage, messages } = useMessages(); + const { fields } = useFields(); + const startDate = subMonths(endOfDay(new Date()), 6); + const endDate = endOfDay(new Date()); + const { isMobile } = useMobile(); + + const updateFilter = (name: string, props: Record) => { + onChange(value.map(filter => (filter.name === name ? { ...filter, ...props } : filter))); + }; + + const handleAdd = (name: Key) => { + onChange(value.concat({ name: name.toString(), operator: 'eq', value: '' })); + }; + + const handleChange = (name: string, value: Key) => { + updateFilter(name, { value }); + }; + + const handleSelect = (name: string, operator: Key) => { + updateFilter(name, { operator }); + }; + + const handleRemove = (name: string) => { + onChange(value.filter(filter => filter.name !== name)); + }; + + return ( + + + + + + + {fields + .filter(({ name }) => !exclude.includes(name)) + .map(field => { + const isDisabled = !!value.find(({ name }) => name === field.name); + return ( + + {field.label} + + ); + })} + + + + + + + {fields + .filter(({ name }) => !exclude.includes(name)) + .map(field => { + const isDisabled = !!value.find(({ name }) => name === field.name); + return ( + + {field.label} + + ); + })} + + + + {value.map(filter => { + return ( + + ); + })} + {!value.length && } + + + ); +} diff --git a/src/components/input/FilterBar.tsx b/src/components/input/FilterBar.tsx new file mode 100644 index 0000000..5a52e56 --- /dev/null +++ b/src/components/input/FilterBar.tsx @@ -0,0 +1,155 @@ +import { + Button, + Dialog, + DialogTrigger, + Icon, + Modal, + Row, + Text, + Tooltip, + TooltipTrigger, +} from '@umami/react-zen'; +import { SegmentEditForm } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditForm'; +import { + useFilters, + useFormat, + useMessages, + useNavigation, + useWebsiteSegmentQuery, +} from '@/components/hooks'; +import { Bookmark, X } from '@/components/icons'; +import { isSearchOperator } from '@/lib/params'; + +export function FilterBar({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + const { formatValue } = useFormat(); + const { + router, + pathname, + updateParams, + replaceParams, + query: { segment, cohort }, + } = useNavigation(); + const { filters, operatorLabels } = useFilters(); + const { data, isLoading } = useWebsiteSegmentQuery(websiteId, segment || cohort); + const canSaveSegment = filters.length > 0 && !segment && !cohort && !pathname.includes('/share'); + + const handleCloseFilter = (param: string) => { + router.push(updateParams({ [param]: undefined })); + }; + + const handleResetFilter = () => { + router.push(replaceParams()); + }; + + const handleSegmentRemove = (type: string) => { + router.push(updateParams({ [type]: undefined })); + }; + + if (!filters.length && !segment && !cohort) { + return null; + } + + return ( + + + {segment && !isLoading && ( + handleSegmentRemove('segment')} + /> + )} + {cohort && !isLoading && ( + handleSegmentRemove('cohort')} + /> + )} + {filters.map(filter => { + const { name, label, operator, value } = filter; + const paramValue = isSearchOperator(operator) ? value : formatValue(value, name); + + return ( + handleCloseFilter(name)} + /> + ); + })} + + + + {canSaveSegment && ( + + + + {formatMessage(labels.saveSegment)} + + + )} + + + {({ close }) => { + return ; + }} + + + + + + + {formatMessage(labels.clearAll)} + + + + + ); +} + +const FilterItem = ({ name, label, operator, value, onRemove }) => { + return ( + + + + + {label} + + {operator} + + {value} + + + onRemove(name)} size="xs" style={{ cursor: 'pointer' }}> + + + + + ); +}; diff --git a/src/components/input/FilterButtons.tsx b/src/components/input/FilterButtons.tsx new file mode 100644 index 0000000..ff37fb1 --- /dev/null +++ b/src/components/input/FilterButtons.tsx @@ -0,0 +1,33 @@ +import { Box, ToggleGroup, ToggleGroupItem } from '@umami/react-zen'; +import { useState } from 'react'; + +export interface FilterButtonsProps { + items: { id: string; label: string }[]; + value: string; + onChange?: (value: string) => void; +} + +export function FilterButtons({ items, value, onChange }: FilterButtonsProps) { + const [selected, setSelected] = useState(value); + + const handleChange = (value: string) => { + setSelected(value); + onChange?.(value); + }; + + return ( + + handleChange(e[0])} + disallowEmptySelection={true} + > + {items.map(({ id, label }) => ( + + {label} + + ))} + + + ); +} diff --git a/src/components/input/FilterEditForm.tsx b/src/components/input/FilterEditForm.tsx new file mode 100644 index 0000000..44f4384 --- /dev/null +++ b/src/components/input/FilterEditForm.tsx @@ -0,0 +1,95 @@ +import { Button, Column, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; +import { useState } from 'react'; +import { useFilters, useMessages, useMobile, useNavigation } from '@/components/hooks'; +import { FieldFilters } from '@/components/input/FieldFilters'; +import { SegmentFilters } from '@/components/input/SegmentFilters'; + +export interface FilterEditFormProps { + websiteId?: string; + onChange?: (params: { filters: any[]; segment?: string; cohort?: string }) => void; + onClose?: () => void; +} + +export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormProps) { + const { + query: { segment, cohort }, + pathname, + } = useNavigation(); + const { filters } = useFilters(); + const { formatMessage, labels } = useMessages(); + const [currentFilters, setCurrentFilters] = useState(filters); + const [currentSegment, setCurrentSegment] = useState(segment); + const [currentCohort, setCurrentCohort] = useState(cohort); + const { isMobile } = useMobile(); + const excludeFilters = pathname.includes('/pixels') || pathname.includes('/links'); + + const handleReset = () => { + setCurrentFilters([]); + setCurrentSegment(undefined); + setCurrentCohort(undefined); + }; + + const handleSave = () => { + onChange?.({ + filters: currentFilters.filter(f => f.value), + segment: currentSegment, + cohort: currentCohort, + }); + onClose?.(); + }; + + const handleSegmentChange = (id: string, type: string) => { + setCurrentSegment(type === 'segment' ? id : undefined); + setCurrentCohort(type === 'cohort' ? id : undefined); + }; + + return ( + + + + + {formatMessage(labels.fields)} + {!excludeFilters && ( + <> + {formatMessage(labels.segments)} + {formatMessage(labels.cohorts)} + + )} + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/input/LanguageButton.tsx b/src/components/input/LanguageButton.tsx new file mode 100644 index 0000000..ac43dcb --- /dev/null +++ b/src/components/input/LanguageButton.tsx @@ -0,0 +1,41 @@ +import { Button, Dialog, Grid, Icon, MenuTrigger, Popover, Text } from '@umami/react-zen'; +import { Globe } from 'lucide-react'; +import { useLocale } from '@/components/hooks'; +import { languages } from '@/lib/lang'; + +export function LanguageButton() { + const { locale, saveLocale } = useLocale(); + const items = Object.keys(languages).map(key => ({ ...languages[key], value: key })); + + function handleSelect(value: string) { + saveLocale(value); + } + + return ( + + + + + + {items.map(({ value, label }) => { + return ( + + ); + })} + + + + + ); +} diff --git a/src/components/input/LookupField.tsx b/src/components/input/LookupField.tsx new file mode 100644 index 0000000..c1d419f --- /dev/null +++ b/src/components/input/LookupField.tsx @@ -0,0 +1,65 @@ +import { ComboBox, type ComboBoxProps, ListItem, Loading, useDebounce } from '@umami/react-zen'; +import { endOfDay, subMonths } from 'date-fns'; +import { type SetStateAction, useMemo, useState } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { useMessages, useWebsiteValuesQuery } from '@/components/hooks'; + +export interface LookupFieldProps extends ComboBoxProps { + websiteId: string; + type: string; + value: string; + onChange: (value: string) => void; +} + +export function LookupField({ websiteId, type, value, onChange, ...props }: LookupFieldProps) { + const { formatMessage, messages } = useMessages(); + const [search, setSearch] = useState(value); + const searchValue = useDebounce(search, 300); + const startDate = subMonths(endOfDay(new Date()), 6); + const endDate = endOfDay(new Date()); + + const { data, isLoading } = useWebsiteValuesQuery({ + websiteId, + type, + search: searchValue, + startDate, + endDate, + }); + + const items: string[] = useMemo(() => { + return data?.map(({ value }) => value) || []; + }, [data]); + + const handleSearch = (value: SetStateAction) => { + setSearch(value); + }; + + return ( + { + handleSearch(value); + onChange?.(value); + }} + formValue="text" + allowsEmptyCollection + allowsCustomValue + renderEmptyState={() => + isLoading ? ( + + ) : ( + + ) + } + > + {items.map(item => ( + + {item} + + ))} + + ); +} diff --git a/src/components/input/MenuButton.tsx b/src/components/input/MenuButton.tsx new file mode 100644 index 0000000..bac307f --- /dev/null +++ b/src/components/input/MenuButton.tsx @@ -0,0 +1,32 @@ +import { Button, DialogTrigger, Icon, Menu, Popover } from '@umami/react-zen'; +import type { Key, ReactNode } from 'react'; +import { Ellipsis } from '@/components/icons'; + +export function MenuButton({ + children, + onAction, + isDisabled, +}: { + children: ReactNode; + onAction?: (action: string) => void; + isDisabled?: boolean; +}) { + const handleAction = (key: Key) => { + onAction?.(key.toString()); + }; + + return ( + + + + + {children} + + + + ); +} diff --git a/src/components/input/MobileMenuButton.tsx b/src/components/input/MobileMenuButton.tsx new file mode 100644 index 0000000..5e59cbb --- /dev/null +++ b/src/components/input/MobileMenuButton.tsx @@ -0,0 +1,17 @@ +import { Button, Dialog, type DialogProps, DialogTrigger, Icon, Modal } from '@umami/react-zen'; +import { Menu } from '@/components/icons'; + +export function MobileMenuButton(props: DialogProps) { + return ( + + + + + + + ); +} diff --git a/src/components/input/MonthFilter.tsx b/src/components/input/MonthFilter.tsx new file mode 100644 index 0000000..dec64b0 --- /dev/null +++ b/src/components/input/MonthFilter.tsx @@ -0,0 +1,18 @@ +import { useDateRange, useNavigation } from '@/components/hooks'; +import { getMonthDateRangeValue } from '@/lib/date'; +import { MonthSelect } from './MonthSelect'; + +export function MonthFilter() { + const { router, updateParams } = useNavigation(); + const { + dateRange: { startDate }, + } = useDateRange(); + + const handleMonthSelect = (date: Date) => { + const range = getMonthDateRangeValue(date); + + router.push(updateParams({ date: range, offset: undefined })); + }; + + return ; +} diff --git a/src/components/input/MonthSelect.tsx b/src/components/input/MonthSelect.tsx new file mode 100644 index 0000000..241634e --- /dev/null +++ b/src/components/input/MonthSelect.tsx @@ -0,0 +1,47 @@ +import { ListItem, Row, Select } from '@umami/react-zen'; +import { useLocale } from '@/components/hooks'; +import { formatDate } from '@/lib/date'; + +export function MonthSelect({ date = new Date(), onChange }) { + const { locale } = useLocale(); + const month = date.getMonth(); + const year = date.getFullYear(); + const currentYear = new Date().getFullYear(); + + const months = [...Array(12)].map((_, i) => i); + const years = [...Array(10)].map((_, i) => currentYear - i); + + const handleMonthChange = (month: number) => { + const d = new Date(date); + d.setMonth(month); + onChange?.(d); + }; + const handleYearChange = (year: number) => { + const d = new Date(date); + d.setFullYear(year); + onChange?.(d); + }; + + return ( + + + + + ); +} diff --git a/src/components/input/NavButton.tsx b/src/components/input/NavButton.tsx new file mode 100644 index 0000000..ab77ef0 --- /dev/null +++ b/src/components/input/NavButton.tsx @@ -0,0 +1,188 @@ +import { + Column, + Icon, + IconLabel, + Menu, + MenuItem, + MenuSection, + MenuSeparator, + MenuTrigger, + Popover, + Pressable, + Row, + SubmenuTrigger, + Text, +} from '@umami/react-zen'; +import { ArrowRight } from 'lucide-react'; +import type { Key } from 'react'; +import { + useConfig, + useLoginQuery, + useMessages, + useMobile, + useNavigation, +} from '@/components/hooks'; +import { + BookText, + ChevronRight, + ExternalLink, + LifeBuoy, + LockKeyhole, + LogOut, + Settings, + User, + Users, +} from '@/components/icons'; +import { Switch } from '@/components/svg'; +import { DOCS_URL, LAST_TEAM_CONFIG } from '@/lib/constants'; +import { removeItem } from '@/lib/storage'; + +export interface TeamsButtonProps { + showText?: boolean; + onAction?: (id: any) => void; +} + +export function NavButton({ showText = true }: TeamsButtonProps) { + const { user } = useLoginQuery(); + const { cloudMode } = useConfig(); + const { formatMessage, labels } = useMessages(); + const { teamId, router } = useNavigation(); + const { isMobile } = useMobile(); + const team = user?.teams?.find(({ id }) => id === teamId); + const selectedKeys = new Set([teamId || 'user']); + const label = teamId ? team?.name : user.username; + + const getUrl = (url: string) => { + return cloudMode ? `${process.env.cloudUrl}${url}` : url; + }; + + const handleAction = async (key: Key) => { + if (key === 'user') { + removeItem(LAST_TEAM_CONFIG); + if (cloudMode) { + window.location.href = '/'; + } else { + router.push('/'); + } + } + }; + + return ( + + + + + {teamId ? : } + {showText && {label}} + + {showText && ( + + + + )} + + + + + + + + } label={formatMessage(labels.switchAccount)} /> + + + + + + + } label={user.username} /> + + + + + {user?.teams?.map(({ id, name }) => ( + + }> + {name} + + + ))} + {user?.teams?.length === 0 && ( + + + + Manage teams + + + + + + + )} + + + + + + + } + label={formatMessage(labels.settings)} + /> + {cloudMode && ( + <> + } + label={formatMessage(labels.documentation)} + > + + + + + } + label={formatMessage(labels.support)} + /> + + )} + {!cloudMode && user.isAdmin && ( + <> + + } + label={formatMessage(labels.admin)} + /> + + )} + + } + label={formatMessage(labels.logout)} + /> + + + + + ); +} diff --git a/src/components/input/PanelButton.tsx b/src/components/input/PanelButton.tsx new file mode 100644 index 0000000..500c40c --- /dev/null +++ b/src/components/input/PanelButton.tsx @@ -0,0 +1,19 @@ +import { Button, type ButtonProps, Icon } from '@umami/react-zen'; +import { useGlobalState } from '@/components/hooks'; +import { PanelLeft } from '@/components/icons'; + +export function PanelButton(props: ButtonProps) { + const [isCollapsed, setIsCollapsed] = useGlobalState('sidenav-collapsed'); + return ( + + ); +} diff --git a/src/components/input/PreferencesButton.tsx b/src/components/input/PreferencesButton.tsx new file mode 100644 index 0000000..710a7fa --- /dev/null +++ b/src/components/input/PreferencesButton.tsx @@ -0,0 +1,32 @@ +import { Button, Column, DialogTrigger, Icon, Label, Popover } from '@umami/react-zen'; +import { DateRangeSetting } from '@/app/(main)/settings/preferences/DateRangeSetting'; +import { TimezoneSetting } from '@/app/(main)/settings/preferences/TimezoneSetting'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { Settings } from '@/components/icons'; + +export function PreferencesButton() { + const { formatMessage, labels } = useMessages(); + + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/input/ProfileButton.tsx b/src/components/input/ProfileButton.tsx new file mode 100644 index 0000000..505cd88 --- /dev/null +++ b/src/components/input/ProfileButton.tsx @@ -0,0 +1,74 @@ +import { + Button, + Icon, + Menu, + MenuItem, + MenuSection, + MenuSeparator, + MenuTrigger, + Popover, + Row, + Text, +} from '@umami/react-zen'; +import { Fragment } from 'react'; +import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks'; +import { LockKeyhole, LogOut, UserCircle } from '@/components/icons'; + +export function ProfileButton() { + const { formatMessage, labels } = useMessages(); + const { user } = useLoginQuery(); + const { renderUrl } = useNavigation(); + + const items = [ + { + id: 'settings', + label: formatMessage(labels.profile), + path: renderUrl('/settings/profile'), + icon: , + }, + user.isAdmin && + !process.env.cloudMode && { + id: 'admin', + label: formatMessage(labels.admin), + path: '/admin', + icon: , + }, + { + id: 'logout', + label: formatMessage(labels.logout), + path: '/logout', + icon: , + separator: true, + }, + ].filter(n => n); + + return ( + + + + + + + {items.map(({ id, path, label, icon, separator }) => { + return ( + + {separator && } + + + {icon} + {label} + + + + ); + })} + + + + + ); +} diff --git a/src/components/input/RefreshButton.tsx b/src/components/input/RefreshButton.tsx new file mode 100644 index 0000000..b52f830 --- /dev/null +++ b/src/components/input/RefreshButton.tsx @@ -0,0 +1,32 @@ +import { Icon, LoadingButton, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import { useDateRange, useMessages } from '@/components/hooks'; +import { RefreshCw } from '@/components/icons'; +import { setWebsiteDateRange } from '@/store/websites'; + +export function RefreshButton({ + websiteId, + isLoading, +}: { + websiteId: string; + isLoading?: boolean; +}) { + const { formatMessage, labels } = useMessages(); + const { dateRange } = useDateRange(); + + function handleClick() { + if (!isLoading && dateRange) { + setWebsiteDateRange(websiteId, dateRange); + } + } + + return ( + + + + + + + {formatMessage(labels.refresh)} + + ); +} diff --git a/src/components/input/ReportEditButton.tsx b/src/components/input/ReportEditButton.tsx new file mode 100644 index 0000000..b333077 --- /dev/null +++ b/src/components/input/ReportEditButton.tsx @@ -0,0 +1,99 @@ +import { + AlertDialog, + Button, + Icon, + Menu, + MenuItem, + MenuTrigger, + Modal, + Popover, + Row, + Text, +} from '@umami/react-zen'; +import { type ReactNode, useState } from 'react'; +import { useMessages } from '@/components/hooks'; +import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery'; +import { Edit, MoreHorizontal, Trash } from '@/components/icons'; + +export function ReportEditButton({ + id, + name, + type, + children, + onDelete, +}: { + id: string; + name: string; + type: string; + onDelete?: () => void; + children: ({ close }: { close: () => void }) => ReactNode; +}) { + const { formatMessage, labels, messages } = useMessages(); + const [showEdit, setShowEdit] = useState(false); + const [showDelete, setShowDelete] = useState(false); + const { mutateAsync, touch } = useDeleteQuery(`/reports/${id}`); + + const handleAction = (id: any) => { + if (id === 'edit') { + setShowEdit(true); + } else if (id === 'delete') { + setShowDelete(true); + } + }; + + const handleClose = () => { + setShowEdit(false); + setShowDelete(false); + }; + + const handleDelete = async () => { + await mutateAsync(null, { + onSuccess: async () => { + touch(`reports:${type}`); + setShowDelete(false); + onDelete?.(); + }, + }); + }; + + return ( + <> + + + + + + + + + {formatMessage(labels.edit)} + + + + + + {formatMessage(labels.delete)} + + + + + + {showEdit && children({ close: handleClose })} + {showDelete && ( + + {formatMessage(messages.confirmDelete, { target: name })} + + )} + + + ); +} diff --git a/src/components/input/SegmentFilters.tsx b/src/components/input/SegmentFilters.tsx new file mode 100644 index 0000000..f03a1de --- /dev/null +++ b/src/components/input/SegmentFilters.tsx @@ -0,0 +1,42 @@ +import { IconLabel, List, ListItem } from '@umami/react-zen'; +import { Empty } from '@/components/common/Empty'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useWebsiteSegmentsQuery } from '@/components/hooks'; +import { ChartPie, UserPlus } from '@/components/icons'; + +export interface SegmentFiltersProps { + websiteId: string; + segmentId: string; + type?: string; + onChange?: (id: string, type: string) => void; +} + +export function SegmentFilters({ + websiteId, + segmentId, + type = 'segment', + onChange, +}: SegmentFiltersProps) { + const { data, isLoading, isFetching } = useWebsiteSegmentsQuery(websiteId, { type }); + + const handleChange = (id: string) => { + onChange?.(id, type); + }; + + return ( + + {data?.data?.length === 0 && } + handleChange(id[0])}> + {data?.data?.map(item => { + return ( + + : }> + {item.name} + + + ); + })} + + + ); +} diff --git a/src/components/input/SegmentSaveButton.tsx b/src/components/input/SegmentSaveButton.tsx new file mode 100644 index 0000000..5f6cac1 --- /dev/null +++ b/src/components/input/SegmentSaveButton.tsx @@ -0,0 +1,26 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen'; +import { SegmentEditForm } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditForm'; +import { useMessages } from '@/components/hooks'; +import { Plus } from '@/components/icons'; + +export function SegmentSaveButton({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + + + + + {({ close }) => { + return ; + }} + + + + ); +} diff --git a/src/components/input/SettingsButton.tsx b/src/components/input/SettingsButton.tsx new file mode 100644 index 0000000..bd51fb5 --- /dev/null +++ b/src/components/input/SettingsButton.tsx @@ -0,0 +1,84 @@ +import { + Button, + Icon, + Menu, + MenuItem, + MenuSection, + MenuSeparator, + MenuTrigger, + Popover, +} from '@umami/react-zen'; +import type { Key } from 'react'; +import { useConfig, useLoginQuery, useMessages, useNavigation } from '@/components/hooks'; +import { + BookText, + ExternalLink, + LifeBuoy, + LockKeyhole, + LogOut, + Settings, + UserCircle, +} from '@/components/icons'; +import { DOCS_URL } from '@/lib/constants'; + +export function SettingsButton() { + const { formatMessage, labels } = useMessages(); + const { user } = useLoginQuery(); + const { router } = useNavigation(); + const { cloudMode } = useConfig(); + + const handleAction = (id: Key) => { + const url = id.toString(); + + if (cloudMode) { + if (url === '/docs') { + window.open(DOCS_URL, '_blank'); + } else { + window.location.href = url; + } + } else { + router.push(url); + } + }; + + return ( + + + + + + + } label={formatMessage(labels.settings)} /> + {!cloudMode && user.isAdmin && ( + } label={formatMessage(labels.admin)} /> + )} + {cloudMode && ( + <> + } + label={formatMessage(labels.documentation)} + > + + + + + } + label={formatMessage(labels.support)} + /> + + )} + + } label={formatMessage(labels.logout)} /> + + + + + ); +} diff --git a/src/components/input/WebsiteDateFilter.tsx b/src/components/input/WebsiteDateFilter.tsx new file mode 100644 index 0000000..18b4f13 --- /dev/null +++ b/src/components/input/WebsiteDateFilter.tsx @@ -0,0 +1,102 @@ +import { Button, Icon, ListItem, Row, Select, Text } from '@umami/react-zen'; +import { isAfter } from 'date-fns'; +import { useMemo } from 'react'; +import { useDateRange, useDateRangeQuery, useMessages, useNavigation } from '@/components/hooks'; +import { ChevronRight } from '@/components/icons'; +import { getDateRangeValue } from '@/lib/date'; +import { DateFilter } from './DateFilter'; + +export interface WebsiteDateFilterProps { + websiteId: string; + compare?: string; + showAllTime?: boolean; + showButtons?: boolean; + allowCompare?: boolean; +} + +export function WebsiteDateFilter({ + websiteId, + showAllTime = true, + showButtons = true, + allowCompare, +}: WebsiteDateFilterProps) { + const { dateRange, isAllTime, isCustomRange } = useDateRange(); + const { formatMessage, labels } = useMessages(); + const { + router, + updateParams, + query: { compare = 'prev', offset = 0 }, + } = useNavigation(); + const disableForward = isAllTime || isAfter(dateRange.endDate, new Date()); + const showCompare = allowCompare && !isAllTime; + + const websiteDateRange = useDateRangeQuery(websiteId); + + const handleChange = (date: string) => { + if (date === 'all') { + router.push( + updateParams({ + date: `${getDateRangeValue(websiteDateRange.startDate, websiteDateRange.endDate)}:all`, + offset: undefined, + }), + ); + } else { + router.push(updateParams({ date, offset: undefined })); + } + }; + + const handleIncrement = increment => { + router.push(updateParams({ offset: Number(offset) + increment })); + }; + const handleSelect = (compare: any) => { + router.push(updateParams({ compare })); + }; + + const dateValue = useMemo(() => { + return offset !== 0 + ? getDateRangeValue(dateRange.startDate, dateRange.endDate) + : dateRange.value; + }, [dateRange]); + + return ( + + {showButtons && !isAllTime && !isCustomRange && ( + + + + + )} + + + + {showCompare && ( + + VS + + + + + )} + + ); +} diff --git a/src/components/input/WebsiteFilterButton.tsx b/src/components/input/WebsiteFilterButton.tsx new file mode 100644 index 0000000..7db850a --- /dev/null +++ b/src/components/input/WebsiteFilterButton.tsx @@ -0,0 +1,32 @@ +import { useMessages, useNavigation } from '@/components/hooks'; +import { ListFilter } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { FilterEditForm } from '@/components/input/FilterEditForm'; +import { filtersArrayToObject } from '@/lib/params'; + +export function WebsiteFilterButton({ + websiteId, +}: { + websiteId: string; + position?: 'bottom' | 'top' | 'left' | 'right'; + alignment?: 'end' | 'center' | 'start'; +}) { + const { formatMessage, labels } = useMessages(); + const { updateParams, router } = useNavigation(); + + const handleChange = ({ filters, segment, cohort }: any) => { + const params = filtersArrayToObject(filters); + + const url = updateParams({ ...params, segment, cohort }); + + router.push(url); + }; + + return ( + } label={formatMessage(labels.filter)} variant="outline"> + {({ close }) => { + return ; + }} + + ); +} diff --git a/src/components/input/WebsiteSelect.tsx b/src/components/input/WebsiteSelect.tsx new file mode 100644 index 0000000..8d81eb9 --- /dev/null +++ b/src/components/input/WebsiteSelect.tsx @@ -0,0 +1,74 @@ +import { ListItem, Row, Select, type SelectProps, Text } from '@umami/react-zen'; +import { useState } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { + useLoginQuery, + useMessages, + useUserWebsitesQuery, + useWebsiteQuery, +} from '@/components/hooks'; + +export function WebsiteSelect({ + websiteId, + teamId, + onChange, + includeTeams, + ...props +}: { + websiteId?: string; + teamId?: string; + includeTeams?: boolean; +} & SelectProps) { + const { formatMessage, messages } = useMessages(); + const { data: website } = useWebsiteQuery(websiteId); + const [name, setName] = useState(website?.name); + const [search, setSearch] = useState(''); + const { user } = useLoginQuery(); + const { data, isLoading } = useUserWebsitesQuery( + { userId: user?.id, teamId }, + { search, pageSize: 10, includeTeams }, + ); + const listItems: { id: string; name: string }[] = data?.data || []; + + const handleSearch = (value: string) => { + setSearch(value); + }; + + const handleOpenChange = () => { + setSearch(''); + }; + + const handleChange = (id: string) => { + setName(listItems.find(item => item.id === id)?.name); + onChange(id); + }; + + const renderValue = () => { + return ( + + {name} + + ); + }; + + return ( + + ); +} diff --git a/src/components/messages.ts b/src/components/messages.ts new file mode 100644 index 0000000..0438c06 --- /dev/null +++ b/src/components/messages.ts @@ -0,0 +1,518 @@ +import { defineMessages } from 'react-intl'; + +export const labels = defineMessages({ + ok: { id: 'label.ok', defaultMessage: 'OK' }, + unknown: { id: 'label.unknown', defaultMessage: 'Unknown' }, + required: { id: 'label.required', defaultMessage: 'Required' }, + save: { id: 'label.save', defaultMessage: 'Save' }, + cancel: { id: 'label.cancel', defaultMessage: 'Cancel' }, + continue: { id: 'label.continue', defaultMessage: 'Continue' }, + delete: { id: 'label.delete', defaultMessage: 'Delete' }, + leave: { id: 'label.leave', defaultMessage: 'Leave' }, + users: { id: 'label.users', defaultMessage: 'Users' }, + createUser: { id: 'label.create-user', defaultMessage: 'Create user' }, + deleteUser: { id: 'label.delete-user', defaultMessage: 'Delete user' }, + username: { id: 'label.username', defaultMessage: 'Username' }, + password: { id: 'label.password', defaultMessage: 'Password' }, + role: { id: 'label.role', defaultMessage: 'Role' }, + user: { id: 'label.user', defaultMessage: 'User' }, + viewOnly: { id: 'label.view-only', defaultMessage: 'View only' }, + manage: { id: 'label.manage', defaultMessage: 'Manage' }, + admin: { id: 'label.admin', defaultMessage: 'Admin' }, + confirm: { id: 'label.confirm', defaultMessage: 'Confirm' }, + details: { id: 'label.details', defaultMessage: 'Details' }, + website: { id: 'label.website', defaultMessage: 'Website' }, + websites: { id: 'label.websites', defaultMessage: 'Websites' }, + myWebsites: { id: 'label.my-websites', defaultMessage: 'My websites' }, + teamWebsites: { id: 'label.team-websites', defaultMessage: 'Team websites' }, + created: { id: 'label.created', defaultMessage: 'Created' }, + createdBy: { id: 'label.created-by', defaultMessage: 'Created By' }, + edit: { id: 'label.edit', defaultMessage: 'Edit' }, + name: { id: 'label.name', defaultMessage: 'Name' }, + manager: { id: 'label.manager', defaultMessage: 'Manager' }, + member: { id: 'label.member', defaultMessage: 'Member' }, + members: { id: 'label.members', defaultMessage: 'Members' }, + accessCode: { id: 'label.access-code', defaultMessage: 'Access code' }, + teamId: { id: 'label.team-id', defaultMessage: 'Team ID' }, + team: { id: 'label.team', defaultMessage: 'Team' }, + teamName: { id: 'label.team-name', defaultMessage: 'Team name' }, + regenerate: { id: 'label.regenerate', defaultMessage: 'Regenerate' }, + remove: { id: 'label.remove', defaultMessage: 'Remove' }, + join: { id: 'label.join', defaultMessage: 'Join' }, + createTeam: { id: 'label.create-team', defaultMessage: 'Create team' }, + joinTeam: { id: 'label.join-team', defaultMessage: 'Join team' }, + settings: { id: 'label.settings', defaultMessage: 'Settings' }, + owner: { id: 'label.owner', defaultMessage: 'Owner' }, + teamOwner: { id: 'label.team-owner', defaultMessage: 'Team owner' }, + teamManager: { id: 'label.team-manager', defaultMessage: 'Team manager' }, + teamMember: { id: 'label.team-member', defaultMessage: 'Team member' }, + teamViewOnly: { id: 'label.team-view-only', defaultMessage: 'Team view only' }, + enableShareUrl: { id: 'label.enable-share-url', defaultMessage: 'Enable share URL' }, + data: { id: 'label.data', defaultMessage: 'Data' }, + trackingCode: { id: 'label.tracking-code', defaultMessage: 'Tracking code' }, + shareUrl: { id: 'label.share-url', defaultMessage: 'Share URL' }, + action: { id: 'label.action', defaultMessage: 'Action' }, + actions: { id: 'label.actions', defaultMessage: 'Actions' }, + domain: { id: 'label.domain', defaultMessage: 'Domain' }, + websiteId: { id: 'label.website-id', defaultMessage: 'Website ID' }, + resetWebsite: { id: 'label.reset-website', defaultMessage: 'Reset website' }, + deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' }, + transferWebsite: { id: 'label.transfer-website', defaultMessage: 'Transfer website' }, + deleteReport: { id: 'label.delete-report', defaultMessage: 'Delete report' }, + reset: { id: 'label.reset', defaultMessage: 'Reset' }, + addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' }, + addMember: { id: 'label.add-member', defaultMessage: 'Add member' }, + editMember: { id: 'label.edit-member', defaultMessage: 'Edit member' }, + removeMember: { id: 'label.remove-member', defaultMessage: 'Remove member' }, + addDescription: { id: 'label.add-description', defaultMessage: 'Add description' }, + changePassword: { id: 'label.change-password', defaultMessage: 'Change password' }, + currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' }, + newPassword: { id: 'label.new-password', defaultMessage: 'New password' }, + confirmPassword: { id: 'label.confirm-password', defaultMessage: 'Confirm password' }, + timezone: { id: 'label.timezone', defaultMessage: 'Timezone' }, + defaultDateRange: { id: 'label.default-date-range', defaultMessage: 'Default date range' }, + language: { id: 'label.language', defaultMessage: 'Language' }, + theme: { id: 'label.theme', defaultMessage: 'Theme' }, + profile: { id: 'label.profile', defaultMessage: 'Profile' }, + profiles: { id: 'label.profiles', defaultMessage: 'Profiles' }, + dashboard: { id: 'label.dashboard', defaultMessage: 'Dashboard' }, + more: { id: 'label.more', defaultMessage: 'More' }, + realtime: { id: 'label.realtime', defaultMessage: 'Realtime' }, + queries: { id: 'label.queries', defaultMessage: 'Queries' }, + teams: { id: 'label.teams', defaultMessage: 'Teams' }, + teamSettings: { id: 'label.team-settings', defaultMessage: 'Team settings' }, + analytics: { id: 'label.analytics', defaultMessage: 'Analytics' }, + login: { id: 'label.login', defaultMessage: 'Login' }, + logout: { id: 'label.logout', defaultMessage: 'Logout' }, + singleDay: { id: 'label.single-day', defaultMessage: 'Single day' }, + dateRange: { id: 'label.date-range', defaultMessage: 'Date range' }, + viewDetails: { id: 'label.view-details', defaultMessage: 'View details' }, + deleteTeam: { id: 'label.delete-team', defaultMessage: 'Delete team' }, + leaveTeam: { id: 'label.leave-team', defaultMessage: 'Leave team' }, + refresh: { id: 'label.refresh', defaultMessage: 'Refresh' }, + page: { id: 'label.page', defaultMessage: 'Page' }, + pages: { id: 'label.pages', defaultMessage: 'Pages' }, + entry: { id: 'label.entry', defaultMessage: 'Entry' }, + exit: { id: 'label.exit', defaultMessage: 'Exit' }, + referrers: { id: 'label.referrers', defaultMessage: 'Referrers' }, + screen: { id: 'label.screen', defaultMessage: 'Screen' }, + screens: { id: 'label.screens', defaultMessage: 'Screens' }, + browsers: { id: 'label.browsers', defaultMessage: 'Browsers' }, + os: { id: 'label.os', defaultMessage: 'OS' }, + devices: { id: 'label.devices', defaultMessage: 'Devices' }, + countries: { id: 'label.countries', defaultMessage: 'Countries' }, + languages: { id: 'label.languages', defaultMessage: 'Languages' }, + tags: { id: 'label.tags', defaultMessage: 'Tags' }, + segments: { id: 'label.segments', defaultMessage: 'Segments' }, + cohorts: { id: 'label.cohorts', defaultMessage: 'Cohorts' }, + count: { id: 'label.count', defaultMessage: 'Count' }, + average: { id: 'label.average', defaultMessage: 'Average' }, + sum: { id: 'label.sum', defaultMessage: 'Sum' }, + event: { id: 'label.event', defaultMessage: 'Event' }, + events: { id: 'label.events', defaultMessage: 'Events' }, + eventName: { id: 'label.event-name', defaultMessage: 'Event name' }, + query: { id: 'label.query', defaultMessage: 'Query' }, + queryParameters: { id: 'label.query-parameters', defaultMessage: 'Query parameters' }, + back: { id: 'label.back', defaultMessage: 'Back' }, + visitors: { id: 'label.visitors', defaultMessage: 'Visitors' }, + visits: { id: 'label.visits', defaultMessage: 'Visits' }, + filterCombined: { id: 'label.filter-combined', defaultMessage: 'Combined' }, + filterRaw: { id: 'label.filter-raw', defaultMessage: 'Raw' }, + views: { id: 'label.views', defaultMessage: 'Views' }, + none: { id: 'label.none', defaultMessage: 'None' }, + clearAll: { id: 'label.clear-all', defaultMessage: 'Clear all' }, + property: { id: 'label.property', defaultMessage: 'Property' }, + today: { id: 'label.today', defaultMessage: 'Today' }, + lastHours: { id: 'label.last-hours', defaultMessage: 'Last {x} hours' }, + yesterday: { id: 'label.yesterday', defaultMessage: 'Yesterday' }, + thisWeek: { id: 'label.this-week', defaultMessage: 'This week' }, + lastDays: { id: 'label.last-days', defaultMessage: 'Last {x} days' }, + lastMonths: { id: 'label.last-months', defaultMessage: 'Last {x} months' }, + thisMonth: { id: 'label.this-month', defaultMessage: 'This month' }, + thisYear: { id: 'label.this-year', defaultMessage: 'This year' }, + allTime: { id: 'label.all-time', defaultMessage: 'All time' }, + customRange: { id: 'label.custom-range', defaultMessage: 'Custom range' }, + selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' }, + selectRole: { id: 'label.select-role', defaultMessage: 'Select role' }, + selectDate: { id: 'label.select-date', defaultMessage: 'Select date' }, + selectFilter: { id: 'label.select-filter', defaultMessage: 'Select filter' }, + all: { id: 'label.all', defaultMessage: 'All' }, + session: { id: 'label.session', defaultMessage: 'Session' }, + sessions: { id: 'label.sessions', defaultMessage: 'Sessions' }, + distinctId: { id: 'label.distinct-id', defaultMessage: 'Distinct ID' }, + pageNotFound: { id: 'message.page-not-found', defaultMessage: 'Page not found' }, + activity: { id: 'label.activity', defaultMessage: 'Activity' }, + dismiss: { id: 'label.dismiss', defaultMessage: 'Dismiss' }, + poweredBy: { id: 'label.powered-by', defaultMessage: 'Powered by {name}' }, + pageViews: { id: 'label.page-views', defaultMessage: 'Page views' }, + uniqueVisitors: { id: 'label.unique-visitors', defaultMessage: 'Unique visitors' }, + bounceRate: { id: 'label.bounce-rate', defaultMessage: 'Bounce rate' }, + viewsPerVisit: { id: 'label.views-per-visit', defaultMessage: 'Views per visit' }, + visitDuration: { id: 'label.visit-duration', defaultMessage: 'Visit duration' }, + desktop: { id: 'label.desktop', defaultMessage: 'Desktop' }, + laptop: { id: 'label.laptop', defaultMessage: 'Laptop' }, + tablet: { id: 'label.tablet', defaultMessage: 'Tablet' }, + mobile: { id: 'label.mobile', defaultMessage: 'Mobile' }, + toggleCharts: { id: 'label.toggle-charts', defaultMessage: 'Toggle charts' }, + editDashboard: { id: 'label.edit-dashboard', defaultMessage: 'Edit dashboard' }, + title: { id: 'label.title', defaultMessage: 'Title' }, + view: { id: 'label.view', defaultMessage: 'View' }, + cities: { id: 'label.cities', defaultMessage: 'Cities' }, + regions: { id: 'label.regions', defaultMessage: 'Regions' }, + reports: { id: 'label.reports', defaultMessage: 'Reports' }, + eventData: { id: 'label.event-data', defaultMessage: 'Event data' }, + sessionData: { id: 'label.session-data', defaultMessage: 'Session data' }, + funnel: { id: 'label.funnel', defaultMessage: 'Funnel' }, + funnels: { id: 'label.funnels', defaultMessage: 'Funnels' }, + funnelDescription: { + id: 'label.funnel-description', + defaultMessage: 'Understand the conversion and drop-off rate of users.', + }, + revenue: { id: 'label.revenue', defaultMessage: 'Revenue' }, + revenueDescription: { + id: 'label.revenue-description', + defaultMessage: 'Look into your revenue data and how users are spending.', + }, + attribution: { id: 'label.attribution', defaultMessage: 'Attribution' }, + attributionDescription: { + id: 'label.attribution-description', + defaultMessage: 'See how users engage with your marketing and what drives conversions.', + }, + currency: { id: 'label.currency', defaultMessage: 'Currency' }, + model: { id: 'label.model', defaultMessage: 'Model' }, + path: { id: 'label.path', defaultMessage: 'Path' }, + paths: { id: 'label.paths', defaultMessage: 'Paths' }, + add: { id: 'label.add', defaultMessage: 'Add' }, + update: { id: 'label.update', defaultMessage: 'Update' }, + window: { id: 'label.window', defaultMessage: 'Window' }, + runQuery: { id: 'label.run-query', defaultMessage: 'Run query' }, + field: { id: 'label.field', defaultMessage: 'Field' }, + fields: { id: 'label.fields', defaultMessage: 'Fields' }, + createReport: { id: 'label.create-report', defaultMessage: 'Create report' }, + description: { id: 'label.description', defaultMessage: 'Description' }, + untitled: { id: 'label.untitled', defaultMessage: 'Untitled' }, + type: { id: 'label.type', defaultMessage: 'Type' }, + filter: { id: 'label.filter', defaultMessage: 'Filter' }, + filters: { id: 'label.filters', defaultMessage: 'Filters' }, + breakdown: { id: 'label.breakdown', defaultMessage: 'Breakdown' }, + true: { id: 'label.true', defaultMessage: 'True' }, + false: { id: 'label.false', defaultMessage: 'False' }, + is: { id: 'label.is', defaultMessage: 'Is' }, + isNot: { id: 'label.is-not', defaultMessage: 'Is not' }, + isSet: { id: 'label.is-set', defaultMessage: 'Is set' }, + isNotSet: { id: 'label.is-not-set', defaultMessage: 'Is not set' }, + greaterThan: { id: 'label.greater-than', defaultMessage: 'Greater than' }, + lessThan: { id: 'label.less-than', defaultMessage: 'Less than' }, + greaterThanEquals: { id: 'label.greater-than-equals', defaultMessage: 'Greater than or equals' }, + lessThanEquals: { id: 'label.less-than-equals', defaultMessage: 'Less than or equals' }, + contains: { id: 'label.contains', defaultMessage: 'Contains' }, + doesNotContain: { id: 'label.does-not-contain', defaultMessage: 'Does not contain' }, + includes: { id: 'label.includes', defaultMessage: 'Includes' }, + doesNotInclude: { id: 'label.does-not-include', defaultMessage: 'Does not include' }, + before: { id: 'label.before', defaultMessage: 'Before' }, + after: { id: 'label.after', defaultMessage: 'After' }, + isTrue: { id: 'label.is-true', defaultMessage: 'Is true' }, + isFalse: { id: 'label.is-false', defaultMessage: 'Is false' }, + exists: { id: 'label.exists', defaultMessage: 'Exists' }, + doesNotExist: { id: 'label.doest-not-exist', defaultMessage: 'Does not exist' }, + total: { id: 'label.total', defaultMessage: 'Total' }, + min: { id: 'label.min', defaultMessage: 'Min' }, + max: { id: 'label.max', defaultMessage: 'Max' }, + unique: { id: 'label.unique', defaultMessage: 'Unique' }, + value: { id: 'label.value', defaultMessage: 'Value' }, + overview: { id: 'label.overview', defaultMessage: 'Overview' }, + totalRecords: { id: 'label.total-records', defaultMessage: 'Total records' }, + insight: { id: 'label.insight', defaultMessage: 'Insight' }, + insights: { id: 'label.insights', defaultMessage: 'Insights' }, + insightsDescription: { + id: 'label.insights-description', + defaultMessage: 'Dive deeper into your data by using segments and filters.', + }, + retention: { id: 'label.retention', defaultMessage: 'Retention' }, + retentionDescription: { + id: 'label.retention-description', + defaultMessage: 'Measure your website stickiness by tracking how often users return.', + }, + dropoff: { id: 'label.dropoff', defaultMessage: 'Dropoff' }, + referrer: { id: 'label.referrer', defaultMessage: 'Referrer' }, + hostname: { id: 'label.hostname', defaultMessage: 'Hostname' }, + country: { id: 'label.country', defaultMessage: 'Country' }, + region: { id: 'label.region', defaultMessage: 'Region' }, + city: { id: 'label.city', defaultMessage: 'City' }, + browser: { id: 'label.browser', defaultMessage: 'Browser' }, + device: { id: 'label.device', defaultMessage: 'Device' }, + pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' }, + tag: { id: 'label.tag', defaultMessage: 'Tag' }, + segment: { id: 'label.segment', defaultMessage: 'Segment' }, + cohort: { id: 'label.cohort', defaultMessage: 'Cohort' }, + day: { id: 'label.day', defaultMessage: 'Day' }, + date: { id: 'label.date', defaultMessage: 'Date' }, + pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' }, + create: { id: 'label.create', defaultMessage: 'Create' }, + search: { id: 'label.search', defaultMessage: 'Search' }, + numberOfRecords: { + id: 'label.number-of-records', + defaultMessage: '{x} {x, plural, one {record} other {records}}', + }, + select: { id: 'label.select', defaultMessage: 'Select' }, + myAccount: { id: 'label.my-account', defaultMessage: 'My account' }, + transfer: { id: 'label.transfer', defaultMessage: 'Transfer' }, + transactions: { id: 'label.transactions', defaultMessage: 'Transactions' }, + uniqueCustomers: { id: 'label.uniqueCustomers', defaultMessage: 'Unique Customers' }, + viewedPage: { + id: 'message.viewed-page', + defaultMessage: 'Viewed page', + }, + collectedData: { + id: 'message.collected-data', + defaultMessage: 'Collected data', + }, + triggeredEvent: { + id: 'message.triggered-event', + defaultMessage: 'Triggered event', + }, + utm: { id: 'label.utm', defaultMessage: 'UTM' }, + utmDescription: { + id: 'label.utm-description', + defaultMessage: 'Track your campaigns through UTM parameters.', + }, + conversionStep: { id: 'label.conversion-step', defaultMessage: 'Conversion step' }, + conversionRate: { id: 'label.conversion-rate', defaultMessage: 'Conversion rate' }, + steps: { id: 'label.steps', defaultMessage: 'Steps' }, + startStep: { id: 'label.start-step', defaultMessage: 'Start Step' }, + endStep: { id: 'label.end-step', defaultMessage: 'End Step' }, + addStep: { id: 'label.add-step', defaultMessage: 'Add step' }, + goal: { id: 'label.goal', defaultMessage: 'Goal' }, + goals: { id: 'label.goals', defaultMessage: 'Goals' }, + goalsDescription: { + id: 'label.goals-description', + defaultMessage: 'Track your goals for pageviews and events.', + }, + journey: { id: 'label.journey', defaultMessage: 'Journey' }, + journeys: { id: 'label.journeys', defaultMessage: 'Journeys' }, + journeyDescription: { + id: 'label.journey-description', + defaultMessage: 'Understand how users navigate through your website.', + }, + compareDates: { id: 'label.compare-dates', defaultMessage: 'Compare dates' }, + compare: { id: 'label.compare', defaultMessage: 'Compare' }, + current: { id: 'label.current', defaultMessage: 'Current' }, + previous: { id: 'label.previous', defaultMessage: 'Previous' }, + previousPeriod: { id: 'label.previous-period', defaultMessage: 'Previous period' }, + previousYear: { id: 'label.previous-year', defaultMessage: 'Previous year' }, + lastSeen: { id: 'label.last-seen', defaultMessage: 'Last seen' }, + firstSeen: { id: 'label.first-seen', defaultMessage: 'First seen' }, + properties: { id: 'label.properties', defaultMessage: 'Properties' }, + channel: { id: 'label.channel', defaultMessage: 'Channel' }, + channels: { id: 'label.channels', defaultMessage: 'Channels' }, + sources: { id: 'label.sources', defaultMessage: 'Sources' }, + medium: { id: 'label.medium', defaultMessage: 'Medium' }, + campaigns: { id: 'label.campaigns', defaultMessage: 'Campaigns' }, + content: { id: 'label.content', defaultMessage: 'Content' }, + terms: { id: 'label.terms', defaultMessage: 'Terms' }, + direct: { id: 'label.direct', defaultMessage: 'Direct' }, + referral: { id: 'label.referral', defaultMessage: 'Referral' }, + affiliate: { id: 'label.affiliate', defaultMessage: 'Affiliate' }, + email: { id: 'label.email', defaultMessage: 'Email' }, + sms: { id: 'label.sms', defaultMessage: 'SMS' }, + organicSearch: { id: 'label.organic-search', defaultMessage: 'Organic search' }, + organicSocial: { id: 'label.organic-social', defaultMessage: 'Organic social' }, + organicShopping: { id: 'label.organic-shopping', defaultMessage: 'Organic shopping' }, + organicVideo: { id: 'label.organic-video', defaultMessage: 'Organic video' }, + paidAds: { id: 'label.paid-ads', defaultMessage: 'Paid ads' }, + paidSearch: { id: 'label.paid-search', defaultMessage: 'Paid search' }, + paidSocial: { id: 'label.paid-social', defaultMessage: 'Paid social' }, + paidShopping: { id: 'label.paid-shopping', defaultMessage: 'Paid shopping' }, + paidVideo: { id: 'label.paid-video', defaultMessage: 'Paid video' }, + grouped: { id: 'label.grouped', defaultMessage: 'Grouped' }, + other: { id: 'label.other', defaultMessage: 'Other' }, + boards: { id: 'label.boards', defaultMessage: 'Boards' }, + apply: { id: 'label.apply', defaultMessage: 'Apply' }, + link: { id: 'label.link', defaultMessage: 'Link' }, + links: { id: 'label.links', defaultMessage: 'Links' }, + pixel: { id: 'label.pixel', defaultMessage: 'Pixel' }, + pixels: { id: 'label.pixels', defaultMessage: 'Pixels' }, + addBoard: { id: 'label.add-board', defaultMessage: 'Add board' }, + addLink: { id: 'label.add-link', defaultMessage: 'Add link' }, + addPixel: { id: 'label.add-pixel', defaultMessage: 'Add pixel' }, + maximize: { id: 'label.maximize', defaultMessage: 'Maximize' }, + remaining: { id: 'label.remaining', defaultMessage: 'Remaining' }, + conversion: { id: 'label.conversion', defaultMessage: 'Conversion' }, + firstClick: { id: 'label.first-click', defaultMessage: 'First click' }, + lastClick: { id: 'label.last-click', defaultMessage: 'Last click' }, + online: { id: 'label.online', defaultMessage: 'Online' }, + preferences: { id: 'label.preferences', defaultMessage: 'Preferences' }, + location: { id: 'label.location', defaultMessage: 'Location' }, + chart: { id: 'label.chart', defaultMessage: 'Chart' }, + table: { id: 'label.table', defaultMessage: 'Table' }, + download: { id: 'label.download', defaultMessage: 'Download' }, + traffic: { id: 'label.traffic', defaultMessage: 'Traffic' }, + behavior: { id: 'label.behavior', defaultMessage: 'Behavior' }, + growth: { id: 'label.growth', defaultMessage: 'Growth' }, + account: { id: 'label.account', defaultMessage: 'Account' }, + application: { id: 'label.application', defaultMessage: 'Application' }, + saveSegment: { id: 'label.save-segment', defaultMessage: 'Save as segment' }, + saveCohort: { id: 'label.save-cohort', defaultMessage: 'Save as cohort' }, + analysis: { id: 'label.analysis', defaultMessage: 'Analysis' }, + destinationUrl: { id: 'label.destination-url', defaultMessage: 'Destination URL' }, + audience: { id: 'label.audience', defaultMessage: 'Audience' }, + invalidUrl: { id: 'label.invalid-url', defaultMessage: 'Invalid URL' }, + environment: { id: 'label.environment', defaultMessage: 'Environment' }, + criteria: { id: 'label.criteria', defaultMessage: 'Criteria' }, + share: { id: 'label.share', defaultMessage: 'Share' }, + support: { id: 'label.support', defaultMessage: 'Support' }, + documentation: { id: 'label.documentation', defaultMessage: 'Documentation' }, + switchAccount: { id: 'label.switch-account', defaultMessage: 'Switch account' }, +}); + +export const messages = defineMessages({ + error: { id: 'message.error', defaultMessage: 'Something went wrong.' }, + saved: { id: 'message.saved', defaultMessage: 'Saved successfully.' }, + noUsers: { id: 'message.no-users', defaultMessage: 'There are no users.' }, + userDeleted: { id: 'message.user-deleted', defaultMessage: 'User deleted.' }, + noDataAvailable: { id: 'message.no-data-available', defaultMessage: 'No data available.' }, + nothingSelected: { id: 'message.nothing-selected', defaultMessage: 'Nothing selected.' }, + confirmReset: { + id: 'message.confirm-reset', + defaultMessage: 'Are you sure you want to reset {target}?', + }, + confirmDelete: { + id: 'message.confirm-delete', + defaultMessage: 'Are you sure you want to delete {target}?', + }, + confirmRemove: { + id: 'message.confirm-remove', + defaultMessage: 'Are you sure you want to remove {target}?', + }, + confirmLeave: { + id: 'message.confirm-leave', + defaultMessage: 'Are you sure you want to leave {target}?', + }, + minPasswordLength: { + id: 'message.min-password-length', + defaultMessage: 'Minimum length of {n} characters', + }, + noTeams: { + id: 'message.no-teams', + defaultMessage: 'You have not created any teams.', + }, + shareUrl: { + id: 'message.share-url', + defaultMessage: 'Your website stats are publicly available at the following URL:', + }, + trackingCode: { + id: 'message.tracking-code', + defaultMessage: + 'To track stats for this website, place the following code in the ... section of your HTML.', + }, + joinTeamWarning: { + id: 'message.team-already-member', + defaultMessage: 'You are already a member of the team.', + }, + actionConfirmation: { + id: 'message.action-confirmation', + defaultMessage: 'Type {confirmation} in the box below to confirm.', + }, + resetWebsite: { + id: 'message.reset-website', + defaultMessage: 'To reset this website, type {confirmation} in the box below to confirm.', + }, + invalidDomain: { + id: 'message.invalid-domain', + defaultMessage: 'Invalid domain. Do not include http/https.', + }, + resetWebsiteWarning: { + id: 'message.reset-website-warning', + defaultMessage: + 'All statistics for this website will be deleted, but your settings will remain intact.', + }, + deleteWebsiteWarning: { + id: 'message.delete-website-warning', + defaultMessage: 'All website data will be deleted.', + }, + deleteTeamWarning: { + id: 'message.delete-team-warning', + defaultMessage: 'Deleting a team will also delete all team websites.', + }, + noResultsFound: { + id: 'message.no-results-found', + defaultMessage: 'No results found.', + }, + noWebsitesConfigured: { + id: 'message.no-websites-configured', + defaultMessage: 'You do not have any websites configured.', + }, + noTeamWebsites: { + id: 'message.no-team-websites', + defaultMessage: 'This team does not have any websites.', + }, + teamWebsitesInfo: { + id: 'message.team-websites-info', + defaultMessage: 'Websites can be viewed by anyone on the team.', + }, + noMatchPassword: { id: 'message.no-match-password', defaultMessage: 'Passwords do not match.' }, + goToSettings: { + id: 'message.go-to-settings', + defaultMessage: 'Go to settings', + }, + activeUsers: { + id: 'message.active-users', + defaultMessage: '{x} current {x, plural, one {visitor} other {visitors}}', + }, + teamNotFound: { + id: 'message.team-not-found', + defaultMessage: 'Team not found.', + }, + visitorLog: { + id: 'message.visitor-log', + defaultMessage: 'Visitor from {country} using {browser} on {os} {device}', + }, + eventLog: { + id: 'message.event-log', + defaultMessage: '{event} on {url}', + }, + incorrectUsernamePassword: { + id: 'message.incorrect-username-password', + defaultMessage: 'Incorrect username and/or password.', + }, + noEventData: { + id: 'message.no-event-data', + defaultMessage: 'No event data is available.', + }, + newVersionAvailable: { + id: 'message.new-version-available', + defaultMessage: 'A new version of Umami {version} is available!', + }, + transferWebsite: { + id: 'message.transfer-website', + defaultMessage: 'Transfer website ownership to your account or another team.', + }, + transferTeamWebsiteToUser: { + id: 'message.transfer-team-website-to-user', + defaultMessage: 'Transfer this website to your account?', + }, + transferUserWebsiteToTeam: { + id: 'message.transfer-user-website-to-team', + defaultMessage: 'Select the team to transfer this website to.', + }, + unauthorized: { + id: 'message.unauthorized', + defaultMessage: 'Unauthorized', + }, + badRequest: { + id: 'message.bad-request', + defaultMessage: 'Bad request', + }, + forbidden: { + id: 'message.forbidden', + defaultMessage: 'Forbidden', + }, + notFound: { + id: 'message.not-found', + defaultMessage: 'Not found', + }, + serverError: { + id: 'message.sever-error', + defaultMessage: 'Server error', + }, +}); diff --git a/src/components/metrics/ActiveUsers.tsx b/src/components/metrics/ActiveUsers.tsx new file mode 100644 index 0000000..a4bc7da --- /dev/null +++ b/src/components/metrics/ActiveUsers.tsx @@ -0,0 +1,39 @@ +import { StatusLight, Text } from '@umami/react-zen'; +import { useMemo } from 'react'; +import { LinkButton } from '@/components/common/LinkButton'; +import { useActyiveUsersQuery, useMessages } from '@/components/hooks'; + +export function ActiveUsers({ + websiteId, + value, + refetchInterval = 60000, +}: { + websiteId: string; + value?: number; + refetchInterval?: number; +}) { + const { formatMessage, labels } = useMessages(); + const { data } = useActyiveUsersQuery(websiteId, { refetchInterval }); + + const count = useMemo(() => { + if (websiteId) { + return data?.visitors || 0; + } + + return value !== undefined ? value : 0; + }, [data, value, websiteId]); + + if (count === 0) { + return null; + } + + return ( + + + + {count} {formatMessage(labels.online)} + + + + ); +} diff --git a/src/components/metrics/ChangeLabel.tsx b/src/components/metrics/ChangeLabel.tsx new file mode 100644 index 0000000..192f0ff --- /dev/null +++ b/src/components/metrics/ChangeLabel.tsx @@ -0,0 +1,60 @@ +import { Icon, Row, type RowProps, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { ArrowRight } from '@/components/icons'; + +const STYLES = { + positive: { + color: `var(--success-color)`, + background: `color-mix(in srgb, var(--success-color), var(--background-color) 95%)`, + }, + negative: { + color: `var(--danger-color)`, + background: `color-mix(in srgb, var(--danger-color), var(--background-color) 95%)`, + }, + neutral: { + color: `var(--font-color-muted)`, + background: `var(--base-color-2)`, + }, +}; + +export function ChangeLabel({ + value, + size, + reverseColors, + children, + ...props +}: { + value: number; + size?: 'xs' | 'sm' | 'md' | 'lg'; + title?: string; + reverseColors?: boolean; + showPercentage?: boolean; + children?: ReactNode; +} & RowProps) { + const positive = value >= 0; + const negative = value < 0; + const neutral = value === 0 || Number.isNaN(value); + const good = reverseColors ? negative : positive; + + const style = + STYLES[good && 'positive'] || STYLES[!good && 'negative'] || STYLES[neutral && 'neutral']; + + return ( + + {!neutral && ( + + + + )} + {children || value} + + ); +} diff --git a/src/components/metrics/DatePickerForm.tsx b/src/components/metrics/DatePickerForm.tsx new file mode 100644 index 0000000..59d1709 --- /dev/null +++ b/src/components/metrics/DatePickerForm.tsx @@ -0,0 +1,74 @@ +import { Button, Calendar, Column, Row, ToggleGroup, ToggleGroupItem } from '@umami/react-zen'; +import { endOfDay, isAfter, isBefore, isSameDay, startOfDay } from 'date-fns'; +import { useState } from 'react'; +import { useMessages } from '@/components/hooks'; + +const FILTER_DAY = 'filter-day'; +const FILTER_RANGE = 'filter-range'; + +export function DatePickerForm({ + startDate: defaultStartDate, + endDate: defaultEndDate, + minDate, + maxDate, + onChange, + onClose, +}) { + const [selected, setSelected] = useState([ + isSameDay(defaultStartDate, defaultEndDate) ? FILTER_DAY : FILTER_RANGE, + ]); + const [date, setDate] = useState(defaultStartDate || new Date()); + const [startDate, setStartDate] = useState(defaultStartDate || new Date()); + const [endDate, setEndDate] = useState(defaultEndDate || new Date()); + const { formatMessage, labels } = useMessages(); + + const disabled = selected.includes(FILTER_DAY) + ? isAfter(minDate, date) && isBefore(maxDate, date) + : isAfter(startDate, endDate); + + const handleSave = () => { + if (selected.includes(FILTER_DAY)) { + onChange(`range:${startOfDay(date).getTime()}:${endOfDay(date).getTime()}`); + } else { + onChange(`range:${startOfDay(startDate).getTime()}:${endOfDay(endDate).getTime()}`); + } + }; + + return ( + + + + {formatMessage(labels.singleDay)} + {formatMessage(labels.dateRange)} + + + + {selected.includes(FILTER_DAY) && ( + + )} + {selected.includes(FILTER_RANGE) && ( + + + + + )} + + + + + + + ); +} diff --git a/src/components/metrics/EventData.tsx b/src/components/metrics/EventData.tsx new file mode 100644 index 0000000..48d21c5 --- /dev/null +++ b/src/components/metrics/EventData.tsx @@ -0,0 +1,22 @@ +import { Column, Grid, Label, Text } from '@umami/react-zen'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useEventDataQuery } from '@/components/hooks'; + +export function EventData({ websiteId, eventId }: { websiteId: string; eventId: string }) { + const { data, isLoading, error } = useEventDataQuery(websiteId, eventId); + + return ( + + + {data?.map(({ dataKey, stringValue }) => { + return ( + + + {stringValue} + + ); + })} + + + ); +} diff --git a/src/components/metrics/EventsChart.tsx b/src/components/metrics/EventsChart.tsx new file mode 100644 index 0000000..3a53ba9 --- /dev/null +++ b/src/components/metrics/EventsChart.tsx @@ -0,0 +1,93 @@ +import { colord } from 'colord'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { BarChart, type BarChartProps } from '@/components/charts/BarChart'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { + useDateRange, + useLocale, + useTimezone, + useWebsiteEventsSeriesQuery, +} from '@/components/hooks'; +import { renderDateLabels } from '@/lib/charts'; +import { CHART_COLORS } from '@/lib/constants'; +import { generateTimeSeries } from '@/lib/date'; + +export interface EventsChartProps extends BarChartProps { + websiteId: string; + focusLabel?: string; +} + +export function EventsChart({ websiteId, focusLabel }: EventsChartProps) { + const { timezone } = useTimezone(); + const { + dateRange: { startDate, endDate, unit }, + } = useDateRange({ timezone: timezone }); + const { locale, dateLocale } = useLocale(); + const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId); + const [label, setLabel] = useState(focusLabel); + + const chartData: any = useMemo(() => { + if (!data) return; + + const map = (data as any[]).reduce((obj, { x, t, y }) => { + if (!obj[x]) { + obj[x] = []; + } + + obj[x].push({ x: t, y }); + + return obj; + }, {}); + + if (!map || Object.keys(map).length === 0) { + return { + datasets: [ + { + data: generateTimeSeries([], startDate, endDate, unit, dateLocale), + lineTension: 0, + borderWidth: 1, + }, + ], + }; + } else { + return { + datasets: Object.keys(map).map((key, index) => { + const color = colord(CHART_COLORS[index % CHART_COLORS.length]); + return { + label: key, + data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale), + lineTension: 0, + backgroundColor: color.alpha(0.6).toRgbString(), + borderColor: color.alpha(0.7).toRgbString(), + borderWidth: 1, + }; + }), + focusLabel, + }; + } + }, [data, startDate, endDate, unit, focusLabel]); + + useEffect(() => { + if (label !== focusLabel) { + setLabel(focusLabel); + } + }, [focusLabel]); + + const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); + + return ( + + {chartData && ( + + )} + + ); +} diff --git a/src/components/metrics/Legend.tsx b/src/components/metrics/Legend.tsx new file mode 100644 index 0000000..34ddb5a --- /dev/null +++ b/src/components/metrics/Legend.tsx @@ -0,0 +1,39 @@ +import { Row, StatusLight, Text } from '@umami/react-zen'; +import type { LegendItem } from 'chart.js/auto'; +import { colord } from 'colord'; + +export function Legend({ + items = [], + onClick, +}: { + items: any[]; + onClick: (index: LegendItem) => void; +}) { + if (!items.find(({ text }) => text)) { + return null; + } + + return ( + + {items.map(item => { + const { text, fillStyle, hidden } = item; + const color = colord(fillStyle); + + return ( + onClick(item)}> + + + {text} + + + + ); + })} + + ); +} diff --git a/src/components/metrics/ListTable.tsx b/src/components/metrics/ListTable.tsx new file mode 100644 index 0000000..f233bfe --- /dev/null +++ b/src/components/metrics/ListTable.tsx @@ -0,0 +1,152 @@ +import { config, useSpring } from '@react-spring/web'; +import { Column, Grid, Row, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { FixedSizeList } from 'react-window'; +import { AnimatedDiv } from '@/components/common/AnimatedDiv'; +import { Empty } from '@/components/common/Empty'; +import { useMessages, useMobile } from '@/components/hooks'; +import { formatLongCurrency, formatLongNumber } from '@/lib/format'; + +const ITEM_SIZE = 30; + +interface ListData { + label: string; + count: number; + percent: number; +} + +export interface ListTableProps { + data?: ListData[]; + title?: string; + metric?: string; + className?: string; + renderLabel?: (data: ListData, index: number) => ReactNode; + renderChange?: (data: ListData, index: number) => ReactNode; + animate?: boolean; + virtualize?: boolean; + showPercentage?: boolean; + itemCount?: number; + currency?: string; +} + +export function ListTable({ + data = [], + title, + metric, + renderLabel, + renderChange, + animate = true, + virtualize = false, + showPercentage = true, + itemCount = 10, + currency, +}: ListTableProps) { + const { formatMessage, labels } = useMessages(); + const { isPhone } = useMobile(); + + const getRow = (row: ListData, index: number) => { + const { label, count, percent } = row; + + return ( + + ); + }; + + const ListTableRow = ({ index, style }) => { + return
{getRow(data[index], index)}
; + }; + + return ( + + + {title} + + {metric} + + + + {data?.length === 0 && } + {virtualize && data.length > 0 ? ( + + {ListTableRow} + + ) : ( + data.map(getRow) + )} + + + ); +} + +const AnimatedRow = ({ + label, + value = 0, + percent, + change, + animate, + showPercentage = true, + currency, + isPhone, +}) => { + const props = useSpring({ + width: percent, + y: !Number.isNaN(value) ? value : 0, + from: { width: 0, y: 0 }, + config: animate ? config.default : { duration: 0 }, + }); + + return ( + + + + {label} + + + + {change} + + + {currency + ? props.y?.to(n => formatLongCurrency(n, currency)) + : props.y?.to(formatLongNumber)} + + + + {showPercentage && ( + + {props.width.to(n => `${n?.toFixed?.(0)}%`)} + + )} + + ); +}; diff --git a/src/components/metrics/MetricCard.tsx b/src/components/metrics/MetricCard.tsx new file mode 100644 index 0000000..d15bcf1 --- /dev/null +++ b/src/components/metrics/MetricCard.tsx @@ -0,0 +1,56 @@ +import { useSpring } from '@react-spring/web'; +import { Column, Text } from '@umami/react-zen'; +import { AnimatedDiv } from '@/components/common/AnimatedDiv'; +import { ChangeLabel } from '@/components/metrics/ChangeLabel'; +import { formatNumber } from '@/lib/format'; + +export interface MetricCardProps { + value: number; + previousValue?: number; + change?: number; + label?: string; + reverseColors?: boolean; + formatValue?: (n: any) => string; + showLabel?: boolean; + showChange?: boolean; +} + +export const MetricCard = ({ + value = 0, + change = 0, + label, + reverseColors = false, + formatValue = formatNumber, + showLabel = true, + showChange = false, +}: MetricCardProps) => { + const diff = value - change; + const pct = ((value - diff) / diff) * 100; + const props = useSpring({ x: Number(value) || 0, from: { x: 0 } }); + const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } }); + + return ( + + {showLabel && ( + + {label} + + )} + + {props?.x?.to(x => formatValue(x))} + + {showChange && ( + + {changeProps?.x?.to(x => `${Math.abs(~~x)}%`)} + + )} + + ); +}; diff --git a/src/components/metrics/MetricLabel.tsx b/src/components/metrics/MetricLabel.tsx new file mode 100644 index 0000000..31c331f --- /dev/null +++ b/src/components/metrics/MetricLabel.tsx @@ -0,0 +1,142 @@ +import { Row } from '@umami/react-zen'; +import { Favicon } from '@/components/common/Favicon'; +import { FilterLink } from '@/components/common/FilterLink'; +import { TypeIcon } from '@/components/common/TypeIcon'; +import { + useCountryNames, + useFormat, + useLocale, + useMessages, + useRegionNames, +} from '@/components/hooks'; +import { GROUPED_DOMAINS } from '@/lib/constants'; + +export interface MetricLabelProps { + type: string; + data: any; + onClick?: () => void; +} + +export function MetricLabel({ type, data }: MetricLabelProps) { + const { formatMessage, labels } = useMessages(); + const { formatValue, formatCity } = useFormat(); + const { locale } = useLocale(); + const { countryNames } = useCountryNames(locale); + const { getRegionName } = useRegionNames(locale); + + const { label, country, domain } = data; + + switch (type) { + case 'browser': + case 'os': + return ( + } + /> + ); + + case 'channel': + return formatMessage(labels[label]); + + case 'city': + return ( + + ) + } + /> + ); + + case 'region': + return ( + } + /> + ); + + case 'country': + return ( + } + /> + ); + + case 'path': + case 'entry': + case 'exit': + return ( + + ); + + case 'device': + return ( + } + /> + ); + + case 'referrer': + return ( + } + /> + ); + + case 'domain': + if (label === 'Other') { + return `(${formatMessage(labels.other)})`; + } else { + const name = GROUPED_DOMAINS.find(({ domain }) => domain === label)?.name; + + if (!name) { + return null; + } + + return ( + + + {name} + + ); + } + + case 'language': + return formatValue(label, 'language'); + + default: + return ; + } +} diff --git a/src/components/metrics/MetricsBar.tsx b/src/components/metrics/MetricsBar.tsx new file mode 100644 index 0000000..850c6bc --- /dev/null +++ b/src/components/metrics/MetricsBar.tsx @@ -0,0 +1,14 @@ +import { Grid, type GridProps } from '@umami/react-zen'; +import type { ReactNode } from 'react'; + +export interface MetricsBarProps extends GridProps { + children?: ReactNode; +} + +export function MetricsBar({ children, ...props }: MetricsBarProps) { + return ( + + {children} + + ); +} diff --git a/src/components/metrics/MetricsExpandedTable.tsx b/src/components/metrics/MetricsExpandedTable.tsx new file mode 100644 index 0000000..f24c952 --- /dev/null +++ b/src/components/metrics/MetricsExpandedTable.tsx @@ -0,0 +1,139 @@ +import { Button, Column, DataColumn, DataTable, Icon, Row, SearchField } from '@umami/react-zen'; +import { type ReactNode, useState } from 'react'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages, useWebsiteExpandedMetricsQuery } from '@/components/hooks'; +import { X } from '@/components/icons'; +import { DownloadButton } from '@/components/input/DownloadButton'; +import { MetricLabel } from '@/components/metrics/MetricLabel'; +import { SESSION_COLUMNS } from '@/lib/constants'; +import { formatShortTime } from '@/lib/format'; + +export interface MetricsExpandedTableProps { + websiteId: string; + type?: string; + title?: string; + dataFilter?: (data: any) => any; + onSearch?: (search: string) => void; + params?: { [key: string]: any }; + allowSearch?: boolean; + allowDownload?: boolean; + renderLabel?: (row: any, index: number) => ReactNode; + onClose?: () => void; + children?: ReactNode; +} + +export function MetricsExpandedTable({ + websiteId, + type, + title, + params, + allowSearch = true, + allowDownload = true, + onClose, + children, +}: MetricsExpandedTableProps) { + const [search, setSearch] = useState(''); + const { formatMessage, labels } = useMessages(); + const isType = ['browser', 'country', 'device', 'os'].includes(type); + const showBounceDuration = SESSION_COLUMNS.includes(type); + + const { data, isLoading, isFetching, error } = useWebsiteExpandedMetricsQuery(websiteId, { + type, + search: isType ? undefined : search, + ...params, + }); + + const items = data?.map(({ name, ...props }) => ({ label: name, ...props })); + + return ( + <> + + {allowSearch && } + + {children} + {allowDownload && } + {onClose && ( + + )} + + + + + {items && ( + + + {row => ( + + + + )} + + + {row => row?.visitors?.toLocaleString()} + + + {row => row?.visits?.toLocaleString()} + + + {row => row?.pageviews?.toLocaleString()} + + {showBounceDuration && [ + + {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/components/metrics/MetricsTable.tsx b/src/components/metrics/MetricsTable.tsx new file mode 100644 index 0000000..e99bd21 --- /dev/null +++ b/src/components/metrics/MetricsTable.tsx @@ -0,0 +1,95 @@ +import { Grid, Icon, Row, Text } from '@umami/react-zen'; +import { useEffect, useMemo } from 'react'; +import { LinkButton } from '@/components/common/LinkButton'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages, useNavigation, useWebsiteMetricsQuery } from '@/components/hooks'; +import { Maximize } from '@/components/icons'; +import { MetricLabel } from '@/components/metrics/MetricLabel'; +import { percentFilter } from '@/lib/filters'; +import { ListTable, type ListTableProps } from './ListTable'; + +export interface MetricsTableProps extends ListTableProps { + websiteId: string; + type: string; + dataFilter?: (data: any) => any; + limit?: number; + showMore?: boolean; + filterLink?: boolean; + params?: Record; + onDataLoad?: (data: any) => void; +} + +export function MetricsTable({ + websiteId, + type, + dataFilter, + limit, + showMore = false, + filterLink = true, + params, + onDataLoad, + ...props +}: MetricsTableProps) { + const { updateParams } = useNavigation(); + const { formatMessage, labels } = useMessages(); + const { data, isLoading, isFetching, error } = useWebsiteMetricsQuery(websiteId, { + type, + limit, + ...params, + }); + + const filteredData = useMemo(() => { + if (data) { + let items = data as any[]; + + if (dataFilter) { + if (Array.isArray(dataFilter)) { + items = dataFilter.reduce((arr, filter) => { + return filter(arr); + }, items); + } else { + items = dataFilter(items); + } + } + + items = percentFilter(items); + + return items.map(({ x, y, z, ...props }) => ({ label: x, count: y, percent: z, ...props })); + } + return []; + }, [data, dataFilter, limit, type]); + + useEffect(() => { + if (data) { + onDataLoad?.(data); + } + }, [data]); + + const renderLabel = (row: any) => { + return filterLink ? : row.label; + }; + + return ( + + + {data && } + {showMore && limit && ( + + + + + + {formatMessage(labels.more)} + + + )} + + + ); +} diff --git a/src/components/metrics/PageviewsChart.tsx b/src/components/metrics/PageviewsChart.tsx new file mode 100644 index 0000000..b83f8dc --- /dev/null +++ b/src/components/metrics/PageviewsChart.tsx @@ -0,0 +1,98 @@ +import { useTheme } from '@umami/react-zen'; +import { useCallback, useMemo } from 'react'; +import { BarChart, type BarChartProps } from '@/components/charts/BarChart'; +import { useLocale, useMessages } from '@/components/hooks'; +import { renderDateLabels } from '@/lib/charts'; +import { getThemeColors } from '@/lib/colors'; +import { generateTimeSeries } from '@/lib/date'; + +export interface PageviewsChartProps extends BarChartProps { + data: { + pageviews: any[]; + sessions: any[]; + compare?: { + pageviews: any[]; + sessions: any[]; + }; + }; + unit: string; +} + +export function PageviewsChart({ data, unit, minDate, maxDate, ...props }: PageviewsChartProps) { + const { formatMessage, labels } = useMessages(); + const { theme } = useTheme(); + const { locale, dateLocale } = useLocale(); + const { colors } = useMemo(() => getThemeColors(theme), [theme]); + + const chartData: any = useMemo(() => { + if (!data) return; + + return { + __id: Date.now(), + datasets: [ + { + type: 'bar', + label: formatMessage(labels.visitors), + data: generateTimeSeries(data.sessions, minDate, maxDate, unit, dateLocale), + borderWidth: 1, + barPercentage: 0.9, + categoryPercentage: 0.9, + ...colors.chart.visitors, + order: 3, + }, + { + type: 'bar', + label: formatMessage(labels.views), + data: generateTimeSeries(data.pageviews, minDate, maxDate, unit, dateLocale), + barPercentage: 0.9, + categoryPercentage: 0.9, + borderWidth: 1, + ...colors.chart.views, + order: 4, + }, + ...(data.compare + ? [ + { + type: 'line', + label: `${formatMessage(labels.views)} (${formatMessage(labels.previous)})`, + data: generateTimeSeries( + data.compare.pageviews, + minDate, + maxDate, + unit, + dateLocale, + ), + borderWidth: 2, + backgroundColor: '#8601B0', + borderColor: '#8601B0', + order: 1, + }, + { + type: 'line', + label: `${formatMessage(labels.visitors)} (${formatMessage(labels.previous)})`, + data: generateTimeSeries(data.compare.sessions, minDate, maxDate, unit, dateLocale), + borderWidth: 2, + backgroundColor: '#f15bb5', + borderColor: '#f15bb5', + order: 2, + }, + ] + : []), + ], + }; + }, [data, locale]); + + const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); + + return ( + + ); +} diff --git a/src/components/metrics/RealtimeChart.tsx b/src/components/metrics/RealtimeChart.tsx new file mode 100644 index 0000000..f42b96d --- /dev/null +++ b/src/components/metrics/RealtimeChart.tsx @@ -0,0 +1,59 @@ +import { isBefore, startOfMinute, subMinutes } from 'date-fns'; +import { useMemo, useRef } from 'react'; +import { useTimezone } from '@/components/hooks'; +import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from '@/lib/constants'; +import type { RealtimeData } from '@/lib/types'; +import { PageviewsChart } from './PageviewsChart'; + +export interface RealtimeChartProps { + data: RealtimeData; + unit: string; + className?: string; +} + +export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) { + const { formatSeriesTimezone, fromUtc, timezone } = useTimezone(); + const endDate = startOfMinute(new Date()); + const startDate = subMinutes(endDate, REALTIME_RANGE); + const prevEndDate = useRef(endDate); + const prevData = useRef(null); + + const chartData = useMemo(() => { + if (!data) { + return { pageviews: [], sessions: [] }; + } + + return { + pageviews: formatSeriesTimezone(data.series.views, 'x', timezone), + sessions: formatSeriesTimezone(data.series.visitors, 'x', timezone), + }; + }, [data, startDate, endDate, unit]); + + const animationDuration = useMemo(() => { + // Don't animate the bars shifting over because it looks weird + if (isBefore(prevEndDate.current, endDate)) { + prevEndDate.current = endDate; + return 0; + } + + // Don't animate when data hasn't changed + const serialized = JSON.stringify(chartData); + if (prevData.current === serialized) { + return 0; + } + prevData.current = serialized; + + return DEFAULT_ANIMATION_DURATION; + }, [endDate, chartData]); + + return ( + + ); +} diff --git a/src/components/metrics/WeeklyTraffic.tsx b/src/components/metrics/WeeklyTraffic.tsx new file mode 100644 index 0000000..90e47c6 --- /dev/null +++ b/src/components/metrics/WeeklyTraffic.tsx @@ -0,0 +1,112 @@ +import { Focusable, Grid, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import { addHours, format, startOfDay } from 'date-fns'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useLocale, useMessages, useWeeklyTrafficQuery } from '@/components/hooks'; +import { getDayOfWeekAsDate } from '@/lib/date'; + +export function WeeklyTraffic({ websiteId }: { websiteId: string }) { + const { data, isLoading, error } = useWeeklyTrafficQuery(websiteId); + const { dateLocale } = useLocale(); + const { labels, formatMessage } = useMessages(); + const { weekStartsOn } = dateLocale.options; + const daysOfWeek = Array(7) + .fill(weekStartsOn) + .map((d, i) => (d + i) % 7); + + const [, max = 1] = data + ? data.reduce((arr: number[], hours: number[], index: number) => { + const min = Math.min(...hours); + const max = Math.max(...hours); + + if (index === 0) { + return [min, max]; + } + + if (min < arr[0]) { + arr[0] = min; + } + + if (max > arr[1]) { + arr[1] = max; + } + + return arr; + }, []) + : []; + + return ( + + + {data && ( + <> + +   + {Array(24) + .fill(null) + .map((_, i) => { + const label = format(addHours(startOfDay(new Date()), i), 'haaa', { + locale: dateLocale, + }); + return ( + + + {label} + + + ); + })} + + {daysOfWeek.map((index: number) => { + const day = data[index]; + return ( + + + + {format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })} + + + {day?.map((count: number, j) => { + const pct = max ? count / max : 0; + return ( + + + + + + + {`${formatMessage( + labels.visitors, + )}: ${count}`} + + ); + })} + + ); + })} + + )} + + + ); +} diff --git a/src/components/metrics/WorldMap.tsx b/src/components/metrics/WorldMap.tsx new file mode 100644 index 0000000..3c8fadb --- /dev/null +++ b/src/components/metrics/WorldMap.tsx @@ -0,0 +1,105 @@ +import { Column, type ColumnProps, FloatingTooltip, useTheme } from '@umami/react-zen'; +import { colord } from 'colord'; +import { useMemo, useState } from 'react'; +import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps'; +import { + useCountryNames, + useLocale, + useMessages, + useWebsiteMetricsQuery, +} from '@/components/hooks'; +import { getThemeColors } from '@/lib/colors'; +import { ISO_COUNTRIES, MAP_FILE } from '@/lib/constants'; +import { percentFilter } from '@/lib/filters'; +import { formatLongNumber } from '@/lib/format'; + +export interface WorldMapProps extends ColumnProps { + websiteId?: string; + data?: any[]; +} + +export function WorldMap({ websiteId, data, ...props }: WorldMapProps) { + const [tooltip, setTooltipPopup] = useState(); + const { theme } = useTheme(); + const { colors } = getThemeColors(theme); + const { locale } = useLocale(); + const { formatMessage, labels } = useMessages(); + const { countryNames } = useCountryNames(locale); + const visitorsLabel = formatMessage(labels.visitors).toLocaleLowerCase(locale); + const unknownLabel = formatMessage(labels.unknown); + + const { data: mapData } = useWebsiteMetricsQuery(websiteId, { + type: 'country', + }); + + const metrics = useMemo( + () => (data || mapData ? percentFilter((data || mapData) as any[]) : []), + [data, mapData], + ); + + const getFillColor = (code: string) => { + if (code === 'AQ') return; + const country = metrics?.find(({ x }) => x === code); + + if (!country) { + return colors.map.fillColor; + } + + return colord(colors.map.baseColor) + [theme === 'light' ? 'lighten' : 'darken'](0.4 * (1.0 - country.z / 100)) + .toHex(); + }; + + const getOpacity = (code: string) => { + return code === 'AQ' ? 0 : 1; + }; + + const handleHover = (code: string) => { + if (code === 'AQ') return; + const country = metrics?.find(({ x }) => x === code); + setTooltipPopup( + `${countryNames[code] || unknownLabel}: ${formatLongNumber( + country?.y || 0, + )} ${visitorsLabel}` as any, + ); + }; + + return ( + + + + + {({ geographies }) => { + return geographies.map(geo => { + const code = ISO_COUNTRIES[geo.id]; + + return ( + handleHover(code)} + onMouseOut={() => setTooltipPopup(null)} + /> + ); + }); + }} + + + + {tooltip && {tooltip}} + + ); +} diff --git a/src/components/svg/AddUser.tsx b/src/components/svg/AddUser.tsx new file mode 100644 index 0000000..d1eb509 --- /dev/null +++ b/src/components/svg/AddUser.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from 'react'; + +const SvgAddUser = (props: SVGProps) => ( + + + + +); +export default SvgAddUser; diff --git a/src/components/svg/BarChart.tsx b/src/components/svg/BarChart.tsx new file mode 100644 index 0000000..96ebe00 --- /dev/null +++ b/src/components/svg/BarChart.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgBarChart = (props: SVGProps) => ( + + + +); +export default SvgBarChart; diff --git a/src/components/svg/Bars.tsx b/src/components/svg/Bars.tsx new file mode 100644 index 0000000..1ce88f7 --- /dev/null +++ b/src/components/svg/Bars.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgBars = (props: SVGProps) => ( + + + +); +export default SvgBars; diff --git a/src/components/svg/Bolt.tsx b/src/components/svg/Bolt.tsx new file mode 100644 index 0000000..23b1e76 --- /dev/null +++ b/src/components/svg/Bolt.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgBolt = (props: SVGProps) => ( + + + +); +export default SvgBolt; diff --git a/src/components/svg/Bookmark.tsx b/src/components/svg/Bookmark.tsx new file mode 100644 index 0000000..089f61f --- /dev/null +++ b/src/components/svg/Bookmark.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgBookmark = (props: SVGProps) => ( + + + +); +export default SvgBookmark; diff --git a/src/components/svg/Calendar.tsx b/src/components/svg/Calendar.tsx new file mode 100644 index 0000000..dfb848a --- /dev/null +++ b/src/components/svg/Calendar.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgCalendar = (props: SVGProps) => ( + + + +); +export default SvgCalendar; diff --git a/src/components/svg/Change.tsx b/src/components/svg/Change.tsx new file mode 100644 index 0000000..935a2f7 --- /dev/null +++ b/src/components/svg/Change.tsx @@ -0,0 +1,13 @@ +import type { SVGProps } from 'react'; + +const SvgChange = (props: SVGProps) => ( + + + +); +export default SvgChange; diff --git a/src/components/svg/Clock.tsx b/src/components/svg/Clock.tsx new file mode 100644 index 0000000..2dfa6a6 --- /dev/null +++ b/src/components/svg/Clock.tsx @@ -0,0 +1,12 @@ +import type { SVGProps } from 'react'; + +const SvgClock = (props: SVGProps) => ( + + + + + + + +); +export default SvgClock; diff --git a/src/components/svg/Compare.tsx b/src/components/svg/Compare.tsx new file mode 100644 index 0000000..3434461 --- /dev/null +++ b/src/components/svg/Compare.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgCompare = (props: SVGProps) => ( + + + +); +export default SvgCompare; diff --git a/src/components/svg/Dashboard.tsx b/src/components/svg/Dashboard.tsx new file mode 100644 index 0000000..5696244 --- /dev/null +++ b/src/components/svg/Dashboard.tsx @@ -0,0 +1,21 @@ +import type { SVGProps } from 'react'; + +const SvgDashboard = (props: SVGProps) => ( + + + + + + +); +export default SvgDashboard; diff --git a/src/components/svg/Download.tsx b/src/components/svg/Download.tsx new file mode 100644 index 0000000..5f58724 --- /dev/null +++ b/src/components/svg/Download.tsx @@ -0,0 +1,9 @@ +import type { SVGProps } from 'react'; + +const SvgDownload = (props: SVGProps) => ( + + + + +); +export default SvgDownload; diff --git a/src/components/svg/Expand.tsx b/src/components/svg/Expand.tsx new file mode 100644 index 0000000..a0f472e --- /dev/null +++ b/src/components/svg/Expand.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react'; + +const SvgExpand = (props: SVGProps) => ( + + + +); +export default SvgExpand; diff --git a/src/components/svg/Export.tsx b/src/components/svg/Export.tsx new file mode 100644 index 0000000..5c1ef14 --- /dev/null +++ b/src/components/svg/Export.tsx @@ -0,0 +1,12 @@ +import type { SVGProps } from 'react'; + +const SvgExport = (props: SVGProps) => ( + + + + + + + +); +export default SvgExport; diff --git a/src/components/svg/Flag.tsx b/src/components/svg/Flag.tsx new file mode 100644 index 0000000..34af943 --- /dev/null +++ b/src/components/svg/Flag.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgFlag = (props: SVGProps) => ( + + + +); +export default SvgFlag; diff --git a/src/components/svg/Funnel.tsx b/src/components/svg/Funnel.tsx new file mode 100644 index 0000000..63cf47d --- /dev/null +++ b/src/components/svg/Funnel.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react'; + +const SvgFunnel = (props: SVGProps) => ( + + + + + + +); +export default SvgFunnel; diff --git a/src/components/svg/Gear.tsx b/src/components/svg/Gear.tsx new file mode 100644 index 0000000..539b838 --- /dev/null +++ b/src/components/svg/Gear.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgGear = (props: SVGProps) => ( + + + +); +export default SvgGear; diff --git a/src/components/svg/Globe.tsx b/src/components/svg/Globe.tsx new file mode 100644 index 0000000..385017d --- /dev/null +++ b/src/components/svg/Globe.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgGlobe = (props: SVGProps) => ( + + + +); +export default SvgGlobe; diff --git a/src/components/svg/Lightbulb.tsx b/src/components/svg/Lightbulb.tsx new file mode 100644 index 0000000..8d86170 --- /dev/null +++ b/src/components/svg/Lightbulb.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react'; + +const SvgLightbulb = (props: SVGProps) => ( + + + + +); +export default SvgLightbulb; diff --git a/src/components/svg/Lightning.tsx b/src/components/svg/Lightning.tsx new file mode 100644 index 0000000..9539a96 --- /dev/null +++ b/src/components/svg/Lightning.tsx @@ -0,0 +1,33 @@ +import type { SVGProps } from 'react'; + +const SvgLightning = (props: SVGProps) => ( + + + + + + + + + + +); +export default SvgLightning; diff --git a/src/components/svg/Link.tsx b/src/components/svg/Link.tsx new file mode 100644 index 0000000..4ce88e7 --- /dev/null +++ b/src/components/svg/Link.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgLink = (props: SVGProps) => ( + + + +); +export default SvgLink; diff --git a/src/components/svg/Location.tsx b/src/components/svg/Location.tsx new file mode 100644 index 0000000..0fd7d16 --- /dev/null +++ b/src/components/svg/Location.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgLocation = (props: SVGProps) => ( + + + +); +export default SvgLocation; diff --git a/src/components/svg/Lock.tsx b/src/components/svg/Lock.tsx new file mode 100644 index 0000000..2b62eb9 --- /dev/null +++ b/src/components/svg/Lock.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgLock = (props: SVGProps) => ( + + + +); +export default SvgLock; diff --git a/src/components/svg/Logo.tsx b/src/components/svg/Logo.tsx new file mode 100644 index 0000000..eb9fdf5 --- /dev/null +++ b/src/components/svg/Logo.tsx @@ -0,0 +1,17 @@ +import type { SVGProps } from 'react'; + +const SvgLogo = (props: SVGProps) => ( + + + + +); +export default SvgLogo; diff --git a/src/components/svg/LogoWhite.tsx b/src/components/svg/LogoWhite.tsx new file mode 100644 index 0000000..fb8c5f9 --- /dev/null +++ b/src/components/svg/LogoWhite.tsx @@ -0,0 +1,26 @@ +import type { SVGProps } from 'react'; + +const SvgLogoWhite = (props: SVGProps) => ( + + + + +); +export default SvgLogoWhite; diff --git a/src/components/svg/Magnet.tsx b/src/components/svg/Magnet.tsx new file mode 100644 index 0000000..88b0f03 --- /dev/null +++ b/src/components/svg/Magnet.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react'; + +const SvgMagnet = (props: SVGProps) => ( + + + +); +export default SvgMagnet; diff --git a/src/components/svg/Money.tsx b/src/components/svg/Money.tsx new file mode 100644 index 0000000..7d7b1e5 --- /dev/null +++ b/src/components/svg/Money.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react'; + +const SvgMoney = (props: SVGProps) => ( + + + + +); +export default SvgMoney; diff --git a/src/components/svg/Moon.tsx b/src/components/svg/Moon.tsx new file mode 100644 index 0000000..40e3e8b --- /dev/null +++ b/src/components/svg/Moon.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgMoon = (props: SVGProps) => ( + + + +); +export default SvgMoon; diff --git a/src/components/svg/Network.tsx b/src/components/svg/Network.tsx new file mode 100644 index 0000000..15941a9 --- /dev/null +++ b/src/components/svg/Network.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react'; + +const SvgNetwork = (props: SVGProps) => ( + + + +); +export default SvgNetwork; diff --git a/src/components/svg/Nodes.tsx b/src/components/svg/Nodes.tsx new file mode 100644 index 0000000..1adfcb8 --- /dev/null +++ b/src/components/svg/Nodes.tsx @@ -0,0 +1,12 @@ +import type { SVGProps } from 'react'; + +const SvgNodes = (props: SVGProps) => ( + + + +); +export default SvgNodes; diff --git a/src/components/svg/Overview.tsx b/src/components/svg/Overview.tsx new file mode 100644 index 0000000..67e6af1 --- /dev/null +++ b/src/components/svg/Overview.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgOverview = (props: SVGProps) => ( + + + +); +export default SvgOverview; diff --git a/src/components/svg/Path.tsx b/src/components/svg/Path.tsx new file mode 100644 index 0000000..7538ba4 --- /dev/null +++ b/src/components/svg/Path.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react'; + +const SvgPath = (props: SVGProps) => ( + + + +); +export default SvgPath; diff --git a/src/components/svg/Profile.tsx b/src/components/svg/Profile.tsx new file mode 100644 index 0000000..c955fce --- /dev/null +++ b/src/components/svg/Profile.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgProfile = (props: SVGProps) => ( + + + +); +export default SvgProfile; diff --git a/src/components/svg/Pushpin.tsx b/src/components/svg/Pushpin.tsx new file mode 100644 index 0000000..d19e98e --- /dev/null +++ b/src/components/svg/Pushpin.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgPushpin = (props: SVGProps) => ( + + + +); +export default SvgPushpin; diff --git a/src/components/svg/Redo.tsx b/src/components/svg/Redo.tsx new file mode 100644 index 0000000..04c389f --- /dev/null +++ b/src/components/svg/Redo.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgRedo = (props: SVGProps) => ( + + + +); +export default SvgRedo; diff --git a/src/components/svg/Reports.tsx b/src/components/svg/Reports.tsx new file mode 100644 index 0000000..b548966 --- /dev/null +++ b/src/components/svg/Reports.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgReports = (props: SVGProps) => ( + + + +); +export default SvgReports; diff --git a/src/components/svg/Security.tsx b/src/components/svg/Security.tsx new file mode 100644 index 0000000..d075a93 --- /dev/null +++ b/src/components/svg/Security.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from 'react'; + +const SvgSecurity = (props: SVGProps) => ( + + + + +); +export default SvgSecurity; diff --git a/src/components/svg/Speaker.tsx b/src/components/svg/Speaker.tsx new file mode 100644 index 0000000..eb724ae --- /dev/null +++ b/src/components/svg/Speaker.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgSpeaker = (props: SVGProps) => ( + + + +); +export default SvgSpeaker; diff --git a/src/components/svg/Sun.tsx b/src/components/svg/Sun.tsx new file mode 100644 index 0000000..61880f5 --- /dev/null +++ b/src/components/svg/Sun.tsx @@ -0,0 +1,9 @@ +import type { SVGProps } from 'react'; + +const SvgSun = (props: SVGProps) => ( + + + + +); +export default SvgSun; diff --git a/src/components/svg/Switch.tsx b/src/components/svg/Switch.tsx new file mode 100644 index 0000000..0196d85 --- /dev/null +++ b/src/components/svg/Switch.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +const SvgSwitch = (props: SVGProps) => ( + + + +); +export default SvgSwitch; diff --git a/src/components/svg/Tag.tsx b/src/components/svg/Tag.tsx new file mode 100644 index 0000000..2ff51f4 --- /dev/null +++ b/src/components/svg/Tag.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from 'react'; + +const SvgTag = (props: SVGProps) => ( + + + + +); +export default SvgTag; diff --git a/src/components/svg/Target.tsx b/src/components/svg/Target.tsx new file mode 100644 index 0000000..3fe76d2 --- /dev/null +++ b/src/components/svg/Target.tsx @@ -0,0 +1,21 @@ +import type { SVGProps } from 'react'; + +const SvgTarget = (props: SVGProps) => ( + + + + + +); +export default SvgTarget; diff --git a/src/components/svg/Visitor.tsx b/src/components/svg/Visitor.tsx new file mode 100644 index 0000000..16db585 --- /dev/null +++ b/src/components/svg/Visitor.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgVisitor = (props: SVGProps) => ( + + + +); +export default SvgVisitor; diff --git a/src/components/svg/Website.tsx b/src/components/svg/Website.tsx new file mode 100644 index 0000000..20a18a4 --- /dev/null +++ b/src/components/svg/Website.tsx @@ -0,0 +1,13 @@ +import type { SVGProps } from 'react'; + +const SvgWebsite = (props: SVGProps) => ( + + + +); +export default SvgWebsite; diff --git a/src/components/svg/index.ts b/src/components/svg/index.ts new file mode 100644 index 0000000..76756af --- /dev/null +++ b/src/components/svg/index.ts @@ -0,0 +1,37 @@ +export { default as AddUser } from './AddUser'; +export { default as BarChart } from './BarChart'; +export { default as Bars } from './Bars'; +export { default as Bolt } from './Bolt'; +export { default as Bookmark } from './Bookmark'; +export { default as Change } from './Change'; +export { default as Compare } from './Compare'; +export { default as Dashboard } from './Dashboard'; +export { default as Download } from './Download'; +export { default as Expand } from './Expand'; +export { default as Export } from './Export'; +export { default as Flag } from './Flag'; +export { default as Funnel } from './Funnel'; +export { default as Gear } from './Gear'; +export { default as Lightbulb } from './Lightbulb'; +export { default as Lightning } from './Lightning'; +export { default as Location } from './Location'; +export { default as Lock } from './Lock'; +export { default as Logo } from './Logo'; +export { default as LogoWhite } from './LogoWhite'; +export { default as Magnet } from './Magnet'; +export { default as Money } from './Money'; +export { default as Network } from './Network'; +export { default as Nodes } from './Nodes'; +export { default as Overview } from './Overview'; +export { default as Path } from './Path'; +export { default as Profile } from './Profile'; +export { default as Pushpin } from './Pushpin'; +export { default as Redo } from './Redo'; +export { default as Reports } from './Reports'; +export { default as Security } from './Security'; +export { default as Speaker } from './Speaker'; +export { default as Switch } from './Switch'; +export { default as Tag } from './Tag'; +export { default as Target } from './Target'; +export { default as Visitor } from './Visitor'; +export { default as Website } from './Website'; -- cgit v1.2.3