aboutsummaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-24 13:09:50 +0000
committerFuwn <[email protected]>2026-01-24 13:09:50 +0000
commit396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch)
treeb9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/components
downloadumami-main.tar.xz
umami-main.zip
Initial commitHEADmain
Created from https://vercel.com/new
Diffstat (limited to 'src/components')
-rw-r--r--src/components/boards/Board.tsx9
-rw-r--r--src/components/charts/BarChart.tsx131
-rw-r--r--src/components/charts/BubbleChart.tsx31
-rw-r--r--src/components/charts/Chart.tsx130
-rw-r--r--src/components/charts/ChartTooltip.tsx23
-rw-r--r--src/components/charts/PieChart.tsx31
-rw-r--r--src/components/common/ActionForm.tsx15
-rw-r--r--src/components/common/AnimatedDiv.tsx3
-rw-r--r--src/components/common/Avatar.tsx21
-rw-r--r--src/components/common/ConfirmationForm.tsx42
-rw-r--r--src/components/common/DataGrid.tsx107
-rw-r--r--src/components/common/DateDisplay.tsx28
-rw-r--r--src/components/common/DateDistance.tsx19
-rw-r--r--src/components/common/Empty.tsx24
-rw-r--r--src/components/common/EmptyPlaceholder.tsx28
-rw-r--r--src/components/common/ErrorBoundary.tsx38
-rw-r--r--src/components/common/ErrorMessage.tsx16
-rw-r--r--src/components/common/ExternalLink.tsx23
-rw-r--r--src/components/common/Favicon.tsx22
-rw-r--r--src/components/common/FilterLink.tsx49
-rw-r--r--src/components/common/FilterRecord.tsx117
-rw-r--r--src/components/common/GridRow.tsx32
-rw-r--r--src/components/common/LinkButton.tsx41
-rw-r--r--src/components/common/LoadingPanel.tsx71
-rw-r--r--src/components/common/PageBody.tsx42
-rw-r--r--src/components/common/PageHeader.tsx58
-rw-r--r--src/components/common/Pager.tsx60
-rw-r--r--src/components/common/Panel.tsx64
-rw-r--r--src/components/common/SectionHeader.tsx28
-rw-r--r--src/components/common/SideMenu.tsx80
-rw-r--r--src/components/common/TypeConfirmationForm.tsx55
-rw-r--r--src/components/common/TypeIcon.tsx29
-rw-r--r--src/components/hooks/context/useLink.ts6
-rw-r--r--src/components/hooks/context/usePixel.ts6
-rw-r--r--src/components/hooks/context/useTeam.ts6
-rw-r--r--src/components/hooks/context/useUser.ts6
-rw-r--r--src/components/hooks/context/useWebsite.ts6
-rw-r--r--src/components/hooks/index.ts84
-rw-r--r--src/components/hooks/queries/useActiveUsersQuery.ts12
-rw-r--r--src/components/hooks/queries/useDateRangeQuery.ts23
-rw-r--r--src/components/hooks/queries/useDeleteQuery.ts12
-rw-r--r--src/components/hooks/queries/useEventDataEventsQuery.ts27
-rw-r--r--src/components/hooks/queries/useEventDataPropertiesQuery.ts27
-rw-r--r--src/components/hooks/queries/useEventDataQuery.ts27
-rw-r--r--src/components/hooks/queries/useEventDataValuesQuery.ts34
-rw-r--r--src/components/hooks/queries/useLinkQuery.ts15
-rw-r--r--src/components/hooks/queries/useLinksQuery.ts17
-rw-r--r--src/components/hooks/queries/useLoginQuery.ts23
-rw-r--r--src/components/hooks/queries/usePixelQuery.ts15
-rw-r--r--src/components/hooks/queries/usePixelsQuery.ts17
-rw-r--r--src/components/hooks/queries/useRealtimeQuery.ts17
-rw-r--r--src/components/hooks/queries/useReportQuery.ts15
-rw-r--r--src/components/hooks/queries/useReportsQuery.ts19
-rw-r--r--src/components/hooks/queries/useResultQuery.ts44
-rw-r--r--src/components/hooks/queries/useSessionActivityQuery.ts21
-rw-r--r--src/components/hooks/queries/useSessionDataPropertiesQuery.ts27
-rw-r--r--src/components/hooks/queries/useSessionDataQuery.ts12
-rw-r--r--src/components/hooks/queries/useSessionDataValuesQuery.ts32
-rw-r--r--src/components/hooks/queries/useShareTokenQuery.ts25
-rw-r--r--src/components/hooks/queries/useTeamMembersQuery.ts16
-rw-r--r--src/components/hooks/queries/useTeamQuery.ts17
-rw-r--r--src/components/hooks/queries/useTeamWebsitesQuery.ts15
-rw-r--r--src/components/hooks/queries/useTeamsQuery.ts20
-rw-r--r--src/components/hooks/queries/useUpdateQuery.ts15
-rw-r--r--src/components/hooks/queries/useUserQuery.ts17
-rw-r--r--src/components/hooks/queries/useUserTeamsQuery.ts15
-rw-r--r--src/components/hooks/queries/useUserWebsitesQuery.ts31
-rw-r--r--src/components/hooks/queries/useUsersQuery.ts17
-rw-r--r--src/components/hooks/queries/useWebsiteCohortQuery.ts21
-rw-r--r--src/components/hooks/queries/useWebsiteCohortsQuery.ts25
-rw-r--r--src/components/hooks/queries/useWebsiteEventsQuery.ts39
-rw-r--r--src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts18
-rw-r--r--src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts51
-rw-r--r--src/components/hooks/queries/useWebsiteMetricsQuery.ts47
-rw-r--r--src/components/hooks/queries/useWebsitePageviewsQuery.ts36
-rw-r--r--src/components/hooks/queries/useWebsiteQuery.ts17
-rw-r--r--src/components/hooks/queries/useWebsiteSegmentQuery.ts21
-rw-r--r--src/components/hooks/queries/useWebsiteSegmentsQuery.ts24
-rw-r--r--src/components/hooks/queries/useWebsiteSessionQuery.ts13
-rw-r--r--src/components/hooks/queries/useWebsiteSessionStatsQuery.ts17
-rw-r--r--src/components/hooks/queries/useWebsiteSessionsQuery.ts34
-rw-r--r--src/components/hooks/queries/useWebsiteStatsQuery.ts36
-rw-r--r--src/components/hooks/queries/useWebsiteValuesQuery.ts62
-rw-r--r--src/components/hooks/queries/useWebsitesQuery.ts20
-rw-r--r--src/components/hooks/queries/useWeeklyTrafficQuery.ts28
-rw-r--r--src/components/hooks/useApi.ts67
-rw-r--r--src/components/hooks/useConfig.ts33
-rw-r--r--src/components/hooks/useCountryNames.ts32
-rw-r--r--src/components/hooks/useDateParameters.ts18
-rw-r--r--src/components/hooks/useDateRange.ts37
-rw-r--r--src/components/hooks/useDocumentClick.ts13
-rw-r--r--src/components/hooks/useEscapeKey.ts19
-rw-r--r--src/components/hooks/useFields.ts23
-rw-r--r--src/components/hooks/useFilterParameters.ts70
-rw-r--r--src/components/hooks/useFilters.ts99
-rw-r--r--src/components/hooks/useForceUpdate.ts9
-rw-r--r--src/components/hooks/useFormat.ts74
-rw-r--r--src/components/hooks/useGlobalState.ts13
-rw-r--r--src/components/hooks/useLanguageNames.ts32
-rw-r--r--src/components/hooks/useLocale.ts60
-rw-r--r--src/components/hooks/useMessages.ts48
-rw-r--r--src/components/hooks/useMobile.ts9
-rw-r--r--src/components/hooks/useModified.ts13
-rw-r--r--src/components/hooks/useNavigation.ts43
-rw-r--r--src/components/hooks/usePageParameters.ts16
-rw-r--r--src/components/hooks/usePagedQuery.ts27
-rw-r--r--src/components/hooks/useRegionNames.ts22
-rw-r--r--src/components/hooks/useSlug.ts14
-rw-r--r--src/components/hooks/useSticky.ts25
-rw-r--r--src/components/hooks/useTimezone.ts95
-rw-r--r--src/components/icons.ts1
-rw-r--r--src/components/input/ActionSelect.tsx18
-rw-r--r--src/components/input/CurrencySelect.tsx34
-rw-r--r--src/components/input/DateFilter.tsx141
-rw-r--r--src/components/input/DialogButton.tsx64
-rw-r--r--src/components/input/DownloadButton.tsx42
-rw-r--r--src/components/input/ExportButton.tsx64
-rw-r--r--src/components/input/FieldFilters.tsx117
-rw-r--r--src/components/input/FilterBar.tsx155
-rw-r--r--src/components/input/FilterButtons.tsx33
-rw-r--r--src/components/input/FilterEditForm.tsx95
-rw-r--r--src/components/input/LanguageButton.tsx41
-rw-r--r--src/components/input/LookupField.tsx65
-rw-r--r--src/components/input/MenuButton.tsx32
-rw-r--r--src/components/input/MobileMenuButton.tsx17
-rw-r--r--src/components/input/MonthFilter.tsx18
-rw-r--r--src/components/input/MonthSelect.tsx47
-rw-r--r--src/components/input/NavButton.tsx188
-rw-r--r--src/components/input/PanelButton.tsx19
-rw-r--r--src/components/input/PreferencesButton.tsx32
-rw-r--r--src/components/input/ProfileButton.tsx74
-rw-r--r--src/components/input/RefreshButton.tsx32
-rw-r--r--src/components/input/ReportEditButton.tsx99
-rw-r--r--src/components/input/SegmentFilters.tsx42
-rw-r--r--src/components/input/SegmentSaveButton.tsx26
-rw-r--r--src/components/input/SettingsButton.tsx84
-rw-r--r--src/components/input/WebsiteDateFilter.tsx102
-rw-r--r--src/components/input/WebsiteFilterButton.tsx32
-rw-r--r--src/components/input/WebsiteSelect.tsx74
-rw-r--r--src/components/messages.ts518
-rw-r--r--src/components/metrics/ActiveUsers.tsx39
-rw-r--r--src/components/metrics/ChangeLabel.tsx60
-rw-r--r--src/components/metrics/DatePickerForm.tsx74
-rw-r--r--src/components/metrics/EventData.tsx22
-rw-r--r--src/components/metrics/EventsChart.tsx93
-rw-r--r--src/components/metrics/Legend.tsx39
-rw-r--r--src/components/metrics/ListTable.tsx152
-rw-r--r--src/components/metrics/MetricCard.tsx56
-rw-r--r--src/components/metrics/MetricLabel.tsx142
-rw-r--r--src/components/metrics/MetricsBar.tsx14
-rw-r--r--src/components/metrics/MetricsExpandedTable.tsx139
-rw-r--r--src/components/metrics/MetricsTable.tsx95
-rw-r--r--src/components/metrics/PageviewsChart.tsx98
-rw-r--r--src/components/metrics/RealtimeChart.tsx59
-rw-r--r--src/components/metrics/WeeklyTraffic.tsx112
-rw-r--r--src/components/metrics/WorldMap.tsx105
-rw-r--r--src/components/svg/AddUser.tsx16
-rw-r--r--src/components/svg/BarChart.tsx8
-rw-r--r--src/components/svg/Bars.tsx8
-rw-r--r--src/components/svg/Bolt.tsx8
-rw-r--r--src/components/svg/Bookmark.tsx8
-rw-r--r--src/components/svg/Calendar.tsx8
-rw-r--r--src/components/svg/Change.tsx13
-rw-r--r--src/components/svg/Clock.tsx12
-rw-r--r--src/components/svg/Compare.tsx8
-rw-r--r--src/components/svg/Dashboard.tsx21
-rw-r--r--src/components/svg/Download.tsx9
-rw-r--r--src/components/svg/Expand.tsx18
-rw-r--r--src/components/svg/Export.tsx12
-rw-r--r--src/components/svg/Flag.tsx8
-rw-r--r--src/components/svg/Funnel.tsx18
-rw-r--r--src/components/svg/Gear.tsx8
-rw-r--r--src/components/svg/Globe.tsx8
-rw-r--r--src/components/svg/Lightbulb.tsx15
-rw-r--r--src/components/svg/Lightning.tsx33
-rw-r--r--src/components/svg/Link.tsx8
-rw-r--r--src/components/svg/Location.tsx8
-rw-r--r--src/components/svg/Lock.tsx8
-rw-r--r--src/components/svg/Logo.tsx17
-rw-r--r--src/components/svg/LogoWhite.tsx26
-rw-r--r--src/components/svg/Magnet.tsx15
-rw-r--r--src/components/svg/Money.tsx15
-rw-r--r--src/components/svg/Moon.tsx8
-rw-r--r--src/components/svg/Network.tsx15
-rw-r--r--src/components/svg/Nodes.tsx12
-rw-r--r--src/components/svg/Overview.tsx8
-rw-r--r--src/components/svg/Path.tsx15
-rw-r--r--src/components/svg/Profile.tsx8
-rw-r--r--src/components/svg/Pushpin.tsx8
-rw-r--r--src/components/svg/Redo.tsx8
-rw-r--r--src/components/svg/Reports.tsx8
-rw-r--r--src/components/svg/Security.tsx16
-rw-r--r--src/components/svg/Speaker.tsx8
-rw-r--r--src/components/svg/Sun.tsx9
-rw-r--r--src/components/svg/Switch.tsx19
-rw-r--r--src/components/svg/Tag.tsx16
-rw-r--r--src/components/svg/Target.tsx21
-rw-r--r--src/components/svg/Visitor.tsx8
-rw-r--r--src/components/svg/Website.tsx13
-rw-r--r--src/components/svg/index.ts37
200 files changed, 7803 insertions, 0 deletions
diff --git a/src/components/boards/Board.tsx b/src/components/boards/Board.tsx
new file mode 100644
index 0000000..70f0fa0
--- /dev/null
+++ b/src/components/boards/Board.tsx
@@ -0,0 +1,9 @@
+import { Column } from '@umami/react-zen';
+
+export interface BoardProps {
+ children?: React.ReactNode;
+}
+
+export function Board({ children }: BoardProps) {
+ return <Column>{children}</Column>;
+}
diff --git a/src/components/charts/BarChart.tsx b/src/components/charts/BarChart.tsx
new file mode 100644
index 0000000..7bfc72d
--- /dev/null
+++ b/src/components/charts/BarChart.tsx
@@ -0,0 +1,131 @@
+import { useTheme } from '@umami/react-zen';
+import { useMemo, useState } from 'react';
+import { Chart, type ChartProps } from '@/components/charts/Chart';
+import { ChartTooltip } from '@/components/charts/ChartTooltip';
+import { useLocale } from '@/components/hooks';
+import { renderNumberLabels } from '@/lib/charts';
+import { getThemeColors } from '@/lib/colors';
+import { DATE_FORMATS, formatDate } from '@/lib/date';
+import { formatLongCurrency, formatLongNumber } from '@/lib/format';
+
+const dateFormats = {
+ millisecond: 'T',
+ second: 'pp',
+ minute: 'p',
+ hour: 'p - PP',
+ day: 'PPPP',
+ week: 'PPPP',
+ month: 'LLLL yyyy',
+ quarter: 'qqq',
+ year: 'yyyy',
+};
+
+export interface BarChartProps extends ChartProps {
+ unit?: string;
+ stacked?: boolean;
+ currency?: string;
+ renderXLabel?: (label: string, index: number, values: any[]) => string;
+ renderYLabel?: (label: string, index: number, values: any[]) => string;
+ XAxisType?: string;
+ YAxisType?: string;
+ minDate?: Date;
+ maxDate?: Date;
+}
+
+export function BarChart({
+ chartData,
+ renderXLabel,
+ renderYLabel,
+ unit,
+ XAxisType = 'timeseries',
+ YAxisType = 'linear',
+ stacked = false,
+ minDate,
+ maxDate,
+ currency,
+ ...props
+}: BarChartProps) {
+ const [tooltip, setTooltip] = useState(null);
+ const { theme } = useTheme();
+ const { locale } = useLocale();
+ const { colors } = useMemo(() => getThemeColors(theme), [theme]);
+
+ const chartOptions: any = useMemo(() => {
+ return {
+ __id: Date.now(),
+ scales: {
+ x: {
+ type: XAxisType,
+ stacked: true,
+ min: formatDate(minDate, DATE_FORMATS[unit], locale),
+ max: formatDate(maxDate, DATE_FORMATS[unit], locale),
+ offset: true,
+ time: {
+ unit,
+ },
+ grid: {
+ display: false,
+ },
+ border: {
+ color: colors.chart.line,
+ },
+ ticks: {
+ color: colors.chart.text,
+ autoSkip: false,
+ maxRotation: 0,
+ callback: renderXLabel,
+ },
+ },
+ y: {
+ type: YAxisType,
+ min: 0,
+ beginAtZero: true,
+ stacked: !!stacked,
+ grid: {
+ color: colors.chart.line,
+ },
+ border: {
+ color: colors.chart.line,
+ },
+ ticks: {
+ color: colors.chart.text,
+ callback: renderYLabel || renderNumberLabels,
+ },
+ },
+ },
+ };
+ }, [chartData, colors, unit, stacked, renderXLabel, renderYLabel]);
+
+ const handleTooltip = ({ tooltip }: { tooltip: any }) => {
+ const { opacity, labelColors, dataPoints } = tooltip;
+
+ setTooltip(
+ opacity
+ ? {
+ title: formatDate(
+ new Date(dataPoints[0].raw?.d || dataPoints[0].raw?.x || dataPoints[0].raw),
+ dateFormats[unit],
+ locale,
+ ),
+ color: labelColors?.[0]?.backgroundColor,
+ value: currency
+ ? formatLongCurrency(dataPoints[0].raw.y, currency)
+ : `${formatLongNumber(dataPoints[0].raw.y)} ${dataPoints[0].dataset.label}`,
+ }
+ : null,
+ );
+ };
+
+ return (
+ <>
+ <Chart
+ {...props}
+ type="bar"
+ chartData={chartData}
+ chartOptions={chartOptions}
+ onTooltip={handleTooltip}
+ />
+ {tooltip && <ChartTooltip {...tooltip} />}
+ </>
+ );
+}
diff --git a/src/components/charts/BubbleChart.tsx b/src/components/charts/BubbleChart.tsx
new file mode 100644
index 0000000..bf487ac
--- /dev/null
+++ b/src/components/charts/BubbleChart.tsx
@@ -0,0 +1,31 @@
+import { useState } from 'react';
+import { Chart, type ChartProps } from '@/components/charts/Chart';
+import { ChartTooltip } from '@/components/charts/ChartTooltip';
+
+export interface BubbleChartProps extends ChartProps {
+ type?: 'bubble';
+}
+
+export function BubbleChart({ type = 'bubble', ...props }: BubbleChartProps) {
+ const [tooltip, setTooltip] = useState(null);
+
+ const handleTooltip = ({ tooltip }) => {
+ const { opacity, labelColors, title, dataPoints } = tooltip;
+
+ setTooltip(
+ opacity
+ ? {
+ color: labelColors?.[0]?.backgroundColor,
+ value: `${title}: ${dataPoints[0].raw}`,
+ }
+ : null,
+ );
+ };
+
+ return (
+ <>
+ <Chart {...props} type={type} onTooltip={handleTooltip} />
+ {tooltip && <ChartTooltip {...tooltip} />}
+ </>
+ );
+}
diff --git a/src/components/charts/Chart.tsx b/src/components/charts/Chart.tsx
new file mode 100644
index 0000000..b6ae9d7
--- /dev/null
+++ b/src/components/charts/Chart.tsx
@@ -0,0 +1,130 @@
+import { Box, type BoxProps, Column } from '@umami/react-zen';
+import ChartJS, {
+ type ChartData,
+ type ChartOptions,
+ type LegendItem,
+ type UpdateMode,
+} from 'chart.js/auto';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { Legend } from '@/components/metrics/Legend';
+import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants';
+
+ChartJS.defaults.font.family = 'Inter';
+
+export interface ChartProps extends BoxProps {
+ type?: 'bar' | 'bubble' | 'doughnut' | 'pie' | 'line' | 'polarArea' | 'radar' | 'scatter';
+ chartData?: ChartData & { focusLabel?: string };
+ chartOptions?: ChartOptions;
+ updateMode?: UpdateMode;
+ animationDuration?: number;
+ onTooltip?: (model: any) => void;
+}
+
+export function Chart({
+ type,
+ chartData,
+ animationDuration = DEFAULT_ANIMATION_DURATION,
+ updateMode,
+ onTooltip,
+ chartOptions,
+ ...props
+}: ChartProps) {
+ const canvas = useRef(null);
+ const chart = useRef(null);
+ const [legendItems, setLegendItems] = useState([]);
+
+ const options = useMemo(() => {
+ return {
+ responsive: true,
+ maintainAspectRatio: false,
+ animation: {
+ duration: animationDuration,
+ resize: {
+ duration: 0,
+ },
+ active: {
+ duration: 0,
+ },
+ },
+ plugins: {
+ legend: {
+ display: false,
+ },
+ tooltip: {
+ enabled: false,
+ intersect: true,
+ external: onTooltip,
+ },
+ },
+ ...chartOptions,
+ };
+ }, [chartOptions]);
+
+ const handleLegendClick = (item: LegendItem) => {
+ if (type === 'bar') {
+ const { datasetIndex } = item;
+ const meta = chart.current.getDatasetMeta(datasetIndex);
+
+ meta.hidden =
+ meta.hidden === null ? !chart.current.data.datasets[datasetIndex]?.hidden : null;
+ } else {
+ const { index } = item;
+ const meta = chart.current.getDatasetMeta(0);
+ const hidden = !!meta?.data?.[index]?.hidden;
+
+ meta.data[index].hidden = !hidden;
+ chart.current.legend.legendItems[index].hidden = !hidden;
+ }
+
+ chart.current.update(updateMode);
+
+ setLegendItems(chart.current.legend.legendItems);
+ };
+
+ // Create chart
+ useEffect(() => {
+ if (canvas.current) {
+ chart.current = new ChartJS(canvas.current, {
+ type,
+ data: chartData,
+ options,
+ });
+
+ setLegendItems(chart.current.legend.legendItems);
+ }
+
+ return () => {
+ chart.current?.destroy();
+ };
+ }, []);
+
+ // Update chart
+ useEffect(() => {
+ if (chart.current && chartData) {
+ // Replace labels and datasets *in-place*
+ chart.current.data.labels = chartData.labels;
+ chart.current.data.datasets = chartData.datasets;
+
+ if (chartData.focusLabel !== null) {
+ chart.current.data.datasets.forEach((ds: { hidden: boolean; label: any }) => {
+ ds.hidden = chartData.focusLabel ? ds.label !== chartData.focusLabel : false;
+ });
+ }
+
+ chart.current.options = options;
+
+ chart.current.update(updateMode);
+
+ setLegendItems(chart.current.legend.legendItems);
+ }
+ }, [chartData, options, updateMode]);
+
+ return (
+ <Column gap="6">
+ <Box {...props}>
+ <canvas ref={canvas} />
+ </Box>
+ <Legend items={legendItems} onClick={handleLegendClick} />
+ </Column>
+ );
+}
diff --git a/src/components/charts/ChartTooltip.tsx b/src/components/charts/ChartTooltip.tsx
new file mode 100644
index 0000000..95ba2a2
--- /dev/null
+++ b/src/components/charts/ChartTooltip.tsx
@@ -0,0 +1,23 @@
+import { Column, FloatingTooltip, Row, StatusLight } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+
+export function ChartTooltip({
+ title,
+ color,
+ value,
+}: {
+ title?: string;
+ color?: string;
+ value?: ReactNode;
+}) {
+ return (
+ <FloatingTooltip>
+ <Column gap="3" fontSize="1">
+ {title && <Row alignItems="center">{title}</Row>}
+ <Row alignItems="center">
+ <StatusLight color={color}>{value}</StatusLight>
+ </Row>
+ </Column>
+ </FloatingTooltip>
+ );
+}
diff --git a/src/components/charts/PieChart.tsx b/src/components/charts/PieChart.tsx
new file mode 100644
index 0000000..2470fe7
--- /dev/null
+++ b/src/components/charts/PieChart.tsx
@@ -0,0 +1,31 @@
+import { useState } from 'react';
+import { Chart, type ChartProps } from '@/components/charts/Chart';
+import { ChartTooltip } from '@/components/charts/ChartTooltip';
+
+export interface PieChartProps extends ChartProps {
+ type?: 'doughnut' | 'pie';
+}
+
+export function PieChart({ type = 'pie', ...props }: PieChartProps) {
+ const [tooltip, setTooltip] = useState(null);
+
+ const handleTooltip = ({ tooltip }) => {
+ const { opacity, labelColors, title, dataPoints } = tooltip;
+
+ setTooltip(
+ opacity
+ ? {
+ color: labelColors?.[0]?.backgroundColor,
+ value: `${title}: ${dataPoints[0].raw}`,
+ }
+ : null,
+ );
+ };
+
+ return (
+ <>
+ <Chart {...props} type={type} onTooltip={handleTooltip} />
+ {tooltip && <ChartTooltip {...tooltip} />}
+ </>
+ );
+}
diff --git a/src/components/common/ActionForm.tsx b/src/components/common/ActionForm.tsx
new file mode 100644
index 0000000..c6f44e8
--- /dev/null
+++ b/src/components/common/ActionForm.tsx
@@ -0,0 +1,15 @@
+import { Column, Row, Text } from '@umami/react-zen';
+
+export function ActionForm({ label, description, children }) {
+ return (
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Column gap="2">
+ <Text weight="bold">{label}</Text>
+ <Text color="muted">{description}</Text>
+ </Column>
+ <Row alignItems="center" gap>
+ {children}
+ </Row>
+ </Row>
+ );
+}
diff --git a/src/components/common/AnimatedDiv.tsx b/src/components/common/AnimatedDiv.tsx
new file mode 100644
index 0000000..f994897
--- /dev/null
+++ b/src/components/common/AnimatedDiv.tsx
@@ -0,0 +1,3 @@
+import { type AnimatedComponent, animated } from '@react-spring/web';
+
+export const AnimatedDiv: AnimatedComponent<any> = animated.div;
diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx
new file mode 100644
index 0000000..9b198b3
--- /dev/null
+++ b/src/components/common/Avatar.tsx
@@ -0,0 +1,21 @@
+import { lorelei } from '@dicebear/collection';
+import { createAvatar } from '@dicebear/core';
+import { useMemo } from 'react';
+import { getColor, getPastel } from '@/lib/colors';
+
+const lib = lorelei;
+
+export function Avatar({ seed, size = 128, ...props }: { seed: string; size?: number }) {
+ const backgroundColor = getPastel(getColor(seed), 4);
+
+ const avatar = useMemo(() => {
+ return createAvatar(lib, {
+ ...props,
+ seed,
+ size,
+ backgroundColor: [backgroundColor],
+ }).toDataUri();
+ }, []);
+
+ return <img src={avatar} alt="Avatar" style={{ borderRadius: '100%', width: size }} />;
+}
diff --git a/src/components/common/ConfirmationForm.tsx b/src/components/common/ConfirmationForm.tsx
new file mode 100644
index 0000000..b909ef5
--- /dev/null
+++ b/src/components/common/ConfirmationForm.tsx
@@ -0,0 +1,42 @@
+import { Box, Button, Form, FormButtons, FormSubmitButton } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { useMessages } from '@/components/hooks';
+
+export interface ConfirmationFormProps {
+ message: ReactNode;
+ buttonLabel?: ReactNode;
+ buttonVariant?: 'primary' | 'quiet' | 'danger';
+ isLoading?: boolean;
+ error?: string | Error;
+ onConfirm?: () => void;
+ onClose?: () => void;
+}
+
+export function ConfirmationForm({
+ message,
+ buttonLabel,
+ buttonVariant,
+ isLoading,
+ error,
+ onConfirm,
+ onClose,
+}: ConfirmationFormProps) {
+ const { formatMessage, labels, getErrorMessage } = useMessages();
+
+ return (
+ <Form onSubmit={onConfirm} error={getErrorMessage(error)}>
+ <Box marginY="4">{message}</Box>
+ <FormButtons>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <FormSubmitButton
+ data-test="button-confirm"
+ isLoading={isLoading}
+ variant={buttonVariant}
+ isDisabled={false}
+ >
+ {buttonLabel || formatMessage(labels.ok)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/components/common/DataGrid.tsx b/src/components/common/DataGrid.tsx
new file mode 100644
index 0000000..7e07b8d
--- /dev/null
+++ b/src/components/common/DataGrid.tsx
@@ -0,0 +1,107 @@
+import type { UseQueryResult } from '@tanstack/react-query';
+import { Column, Row, SearchField } from '@umami/react-zen';
+import {
+ cloneElement,
+ isValidElement,
+ type ReactElement,
+ type ReactNode,
+ useCallback,
+ useState,
+} from 'react';
+import { Empty } from '@/components/common/Empty';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { Pager } from '@/components/common/Pager';
+import { useMessages, useMobile, useNavigation } from '@/components/hooks';
+import type { PageResult } from '@/lib/types';
+
+const DEFAULT_SEARCH_DELAY = 600;
+
+export interface DataGridProps {
+ query: UseQueryResult<PageResult<any>, any>;
+ searchDelay?: number;
+ allowSearch?: boolean;
+ allowPaging?: boolean;
+ autoFocus?: boolean;
+ renderActions?: () => ReactNode;
+ renderEmpty?: () => ReactNode;
+ children: ReactNode | ((data: any) => ReactNode);
+}
+
+export function DataGrid({
+ query,
+ searchDelay = 600,
+ allowSearch,
+ allowPaging = true,
+ autoFocus,
+ renderActions,
+ renderEmpty = () => <Empty />,
+ children,
+}: DataGridProps) {
+ const { formatMessage, labels } = useMessages();
+ const { data, error, isLoading, isFetching } = query;
+ const { router, updateParams, query: queryParams } = useNavigation();
+ const [search, setSearch] = useState(queryParams?.search || data?.search || '');
+ const showPager = allowPaging && data && data.count > data.pageSize;
+ const { isMobile } = useMobile();
+ const displayMode = isMobile ? 'cards' : undefined;
+
+ const handleSearch = (value: string) => {
+ if (value !== search) {
+ setSearch(value);
+ router.push(updateParams({ search: value, page: 1 }));
+ }
+ };
+
+ const handlePageChange = useCallback(
+ (page: number) => {
+ router.push(updateParams({ search, page }));
+ },
+ [search],
+ );
+
+ const child = data ? (typeof children === 'function' ? children(data) : children) : null;
+
+ return (
+ <Column gap="4" minHeight="300px">
+ {allowSearch && (
+ <Row alignItems="center" justifyContent="space-between" wrap="wrap" gap>
+ <SearchField
+ value={search}
+ onSearch={handleSearch}
+ delay={searchDelay || DEFAULT_SEARCH_DELAY}
+ autoFocus={autoFocus}
+ placeholder={formatMessage(labels.search)}
+ />
+ {renderActions?.()}
+ </Row>
+ )}
+ <LoadingPanel
+ data={data}
+ isLoading={isLoading}
+ isFetching={isFetching}
+ error={error}
+ renderEmpty={renderEmpty}
+ >
+ {data && (
+ <>
+ <Column>
+ {isValidElement(child)
+ ? cloneElement(child as ReactElement<any>, { displayMode })
+ : child}
+ </Column>
+ {showPager && (
+ <Row marginTop="6">
+ <Pager
+ page={data.page}
+ pageSize={data.pageSize}
+ count={data.count}
+ onPageChange={handlePageChange}
+ />
+ </Row>
+ )}
+ </>
+ )}
+ </LoadingPanel>
+ </Column>
+ );
+}
diff --git a/src/components/common/DateDisplay.tsx b/src/components/common/DateDisplay.tsx
new file mode 100644
index 0000000..0bece8a
--- /dev/null
+++ b/src/components/common/DateDisplay.tsx
@@ -0,0 +1,28 @@
+import { Icon, Row, Text } from '@umami/react-zen';
+import { differenceInDays, isSameDay } from 'date-fns';
+import { useLocale } from '@/components/hooks';
+import { Calendar } from '@/components/icons';
+import { formatDate } from '@/lib/date';
+
+export function DateDisplay({ startDate, endDate }) {
+ const { locale } = useLocale();
+ const isSingleDate = differenceInDays(endDate, startDate) === 0;
+
+ return (
+ <Row gap="3" alignItems="center" wrap="nowrap">
+ <Icon>
+ <Calendar />
+ </Icon>
+ <Text wrap="nowrap">
+ {isSingleDate ? (
+ formatDate(startDate, 'PP', locale)
+ ) : (
+ <>
+ {formatDate(startDate, 'PP', locale)}
+ {!isSameDay(startDate, endDate) && ` — ${formatDate(endDate, 'PP', locale)}`}
+ </>
+ )}
+ </Text>
+ </Row>
+ );
+}
diff --git a/src/components/common/DateDistance.tsx b/src/components/common/DateDistance.tsx
new file mode 100644
index 0000000..e8bd278
--- /dev/null
+++ b/src/components/common/DateDistance.tsx
@@ -0,0 +1,19 @@
+import { Text } from '@umami/react-zen';
+import { formatDistanceToNow } from 'date-fns';
+import { useLocale, useTimezone } from '@/components/hooks';
+import { isInvalidDate } from '@/lib/date';
+
+export function DateDistance({ date }: { date: Date }) {
+ const { formatTimezoneDate } = useTimezone();
+ const { dateLocale } = useLocale();
+
+ if (isInvalidDate(date)) {
+ return null;
+ }
+
+ return (
+ <Text title={formatTimezoneDate(date?.toISOString(), 'PPPpp')}>
+ {formatDistanceToNow(date, { addSuffix: true, locale: dateLocale })}
+ </Text>
+ );
+}
diff --git a/src/components/common/Empty.tsx b/src/components/common/Empty.tsx
new file mode 100644
index 0000000..8bd8d82
--- /dev/null
+++ b/src/components/common/Empty.tsx
@@ -0,0 +1,24 @@
+import { Row } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+
+export interface EmptyProps {
+ message?: string;
+}
+
+export function Empty({ message }: EmptyProps) {
+ const { formatMessage, messages } = useMessages();
+
+ return (
+ <Row
+ color="muted"
+ alignItems="center"
+ justifyContent="center"
+ width="100%"
+ height="100%"
+ minHeight="70px"
+ flexGrow={1}
+ >
+ {message || formatMessage(messages.noDataAvailable)}
+ </Row>
+ );
+}
diff --git a/src/components/common/EmptyPlaceholder.tsx b/src/components/common/EmptyPlaceholder.tsx
new file mode 100644
index 0000000..64492e0
--- /dev/null
+++ b/src/components/common/EmptyPlaceholder.tsx
@@ -0,0 +1,28 @@
+import { Column, Icon, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+
+export interface EmptyPlaceholderProps {
+ title?: string;
+ description?: string;
+ icon?: ReactNode;
+ children?: ReactNode;
+}
+
+export function EmptyPlaceholder({ title, description, icon, children }: EmptyPlaceholderProps) {
+ return (
+ <Column alignItems="center" justifyContent="center" gap="5" height="100%" width="100%">
+ {icon && (
+ <Icon color="10" size="xl">
+ {icon}
+ </Icon>
+ )}
+ {title && (
+ <Text weight="bold" size="4">
+ {title}
+ </Text>
+ )}
+ {description && <Text color="muted">{description}</Text>}
+ {children}
+ </Column>
+ );
+}
diff --git a/src/components/common/ErrorBoundary.tsx b/src/components/common/ErrorBoundary.tsx
new file mode 100644
index 0000000..4c0c82e
--- /dev/null
+++ b/src/components/common/ErrorBoundary.tsx
@@ -0,0 +1,38 @@
+import { Button, Column } from '@umami/react-zen';
+import type { ErrorInfo, ReactNode } from 'react';
+import { ErrorBoundary as Boundary } from 'react-error-boundary';
+import { useMessages } from '@/components/hooks';
+
+const logError = (error: Error, info: ErrorInfo) => {
+ // eslint-disable-next-line no-console
+ console.error(error, info.componentStack);
+};
+
+export function ErrorBoundary({ children }: { children: ReactNode }) {
+ const { formatMessage, messages } = useMessages();
+
+ const fallbackRender = ({ error, resetErrorBoundary }) => {
+ return (
+ <Column
+ role="alert"
+ gap
+ width="100%"
+ height="100%"
+ position="absolute"
+ justifyContent="center"
+ alignItems="center"
+ >
+ <h1>{formatMessage(messages.error)}</h1>
+ <h3>{error.message}</h3>
+ <pre>{error.stack}</pre>
+ <Button onClick={resetErrorBoundary}>OK</Button>
+ </Column>
+ );
+ };
+
+ return (
+ <Boundary fallbackRender={fallbackRender} onError={logError}>
+ {children}
+ </Boundary>
+ );
+}
diff --git a/src/components/common/ErrorMessage.tsx b/src/components/common/ErrorMessage.tsx
new file mode 100644
index 0000000..3c30151
--- /dev/null
+++ b/src/components/common/ErrorMessage.tsx
@@ -0,0 +1,16 @@
+import { Icon, Row, Text } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { AlertTriangle } from '@/components/icons';
+
+export function ErrorMessage() {
+ const { formatMessage, messages } = useMessages();
+
+ return (
+ <Row alignItems="center" justifyContent="center" gap>
+ <Icon>
+ <AlertTriangle />
+ </Icon>
+ <Text>{formatMessage(messages.error)}</Text>
+ </Row>
+ );
+}
diff --git a/src/components/common/ExternalLink.tsx b/src/components/common/ExternalLink.tsx
new file mode 100644
index 0000000..dec0d16
--- /dev/null
+++ b/src/components/common/ExternalLink.tsx
@@ -0,0 +1,23 @@
+import { Icon, Row, Text } from '@umami/react-zen';
+import Link, { type LinkProps } from 'next/link';
+import type { ReactNode } from 'react';
+import { ExternalLink as LinkIcon } from '@/components/icons';
+
+export function ExternalLink({
+ href,
+ children,
+ ...props
+}: LinkProps & { href: string; children: ReactNode }) {
+ return (
+ <Row alignItems="center" overflow="hidden" gap>
+ <Text title={href} truncate>
+ <Link {...props} href={href} target="_blank">
+ {children}
+ </Link>
+ </Text>
+ <Icon size="sm" strokeColor="muted">
+ <LinkIcon />
+ </Icon>
+ </Row>
+ );
+}
diff --git a/src/components/common/Favicon.tsx b/src/components/common/Favicon.tsx
new file mode 100644
index 0000000..a6b5e52
--- /dev/null
+++ b/src/components/common/Favicon.tsx
@@ -0,0 +1,22 @@
+import { useConfig } from '@/components/hooks';
+import { FAVICON_URL, GROUPED_DOMAINS } from '@/lib/constants';
+
+function getHostName(url: string) {
+ const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?([^:/\n?=]+)/im);
+ return match && match.length > 1 ? match[1] : null;
+}
+
+export function Favicon({ domain, ...props }) {
+ const config = useConfig();
+
+ if (config?.privateMode) {
+ return null;
+ }
+
+ const url = config?.faviconUrl || FAVICON_URL;
+ const hostName = domain ? getHostName(domain) : null;
+ const domainName = GROUPED_DOMAINS[hostName]?.domain || hostName;
+ const src = hostName ? url.replace(/\{\{\s*domain\s*}}/, domainName) : null;
+
+ return hostName ? <img src={src} width={16} height={16} alt="" {...props} /> : null;
+}
diff --git a/src/components/common/FilterLink.tsx b/src/components/common/FilterLink.tsx
new file mode 100644
index 0000000..d719a37
--- /dev/null
+++ b/src/components/common/FilterLink.tsx
@@ -0,0 +1,49 @@
+import { Icon, Row, Text } from '@umami/react-zen';
+import Link from 'next/link';
+import { type HTMLAttributes, type ReactNode, useState } from 'react';
+import { useMessages, useNavigation } from '@/components/hooks';
+import { ExternalLink } from '@/components/icons';
+
+export interface FilterLinkProps extends HTMLAttributes<HTMLDivElement> {
+ type: string;
+ value: string;
+ label?: string;
+ icon?: ReactNode;
+ externalUrl?: string;
+}
+
+export function FilterLink({ type, value, label, externalUrl, icon }: FilterLinkProps) {
+ const [showLink, setShowLink] = useState(false);
+ const { formatMessage, labels } = useMessages();
+ const { updateParams, query } = useNavigation();
+ const active = query[type] !== undefined;
+ const selected = query[type] === value;
+
+ return (
+ <Row
+ alignItems="center"
+ gap
+ fontWeight={active && selected ? 'bold' : undefined}
+ color={active && !selected ? 'muted' : undefined}
+ onMouseOver={() => setShowLink(true)}
+ onMouseOut={() => setShowLink(false)}
+ >
+ {icon}
+ {!value && `(${label || formatMessage(labels.unknown)})`}
+ {value && (
+ <Text title={label || value} truncate>
+ <Link href={updateParams({ [type]: `eq.${value}` })} replace>
+ {label || value}
+ </Link>
+ </Text>
+ )}
+ {externalUrl && showLink && (
+ <a href={externalUrl} target="_blank" rel="noreferrer noopener">
+ <Icon color="muted">
+ <ExternalLink />
+ </Icon>
+ </a>
+ )}
+ </Row>
+ );
+}
diff --git a/src/components/common/FilterRecord.tsx b/src/components/common/FilterRecord.tsx
new file mode 100644
index 0000000..0400264
--- /dev/null
+++ b/src/components/common/FilterRecord.tsx
@@ -0,0 +1,117 @@
+import { Button, Column, Grid, Icon, Label, ListItem, Select, TextField } from '@umami/react-zen';
+import { useState } from 'react';
+import { Empty } from '@/components/common/Empty';
+import { useFilters, useFormat, useWebsiteValuesQuery } from '@/components/hooks';
+import { X } from '@/components/icons';
+import { isSearchOperator } from '@/lib/params';
+
+export interface FilterRecordProps {
+ websiteId: string;
+ type: string;
+ startDate: Date;
+ endDate: Date;
+ name: string;
+ operator: string;
+ value: string;
+ onSelect?: (name: string, value: any) => void;
+ onRemove?: (name: string) => void;
+ onChange?: (name: string, value: string) => void;
+}
+
+export function FilterRecord({
+ websiteId,
+ type,
+ startDate,
+ endDate,
+ name,
+ operator,
+ value,
+ onSelect,
+ onRemove,
+ onChange,
+}: FilterRecordProps) {
+ const { fields, operators } = useFilters();
+ const [selected, setSelected] = useState(value);
+ const [search, setSearch] = useState('');
+ const { formatValue } = useFormat();
+ const { data, isLoading } = useWebsiteValuesQuery({
+ websiteId,
+ type,
+ search,
+ startDate,
+ endDate,
+ });
+ const isSearch = isSearchOperator(operator);
+ const items = data?.filter(({ value }) => value) || [];
+
+ const handleSearch = (value: string) => {
+ setSearch(value);
+ };
+
+ const handleSelectOperator = (value: any) => {
+ onSelect?.(name, value);
+ };
+
+ const handleSelectValue = (value: string) => {
+ setSelected(value);
+ onChange?.(name, value);
+ };
+
+ const renderValue = () => {
+ return formatValue(selected, type);
+ };
+
+ return (
+ <Column>
+ <Label>{fields.find(f => f.name === name)?.label}</Label>
+ <Grid columns="1fr auto" gap>
+ <Grid columns={{ xs: '1fr', md: '200px 1fr' }} gap>
+ <Select
+ items={operators.filter(({ type }) => type === 'string')}
+ value={operator}
+ onChange={handleSelectOperator}
+ >
+ {({ name, label }: any) => {
+ return (
+ <ListItem key={name} id={name}>
+ {label}
+ </ListItem>
+ );
+ }}
+ </Select>
+ {isSearch && (
+ <TextField value={selected} defaultValue={selected} onChange={handleSelectValue} />
+ )}
+ {!isSearch && (
+ <Select
+ items={items}
+ value={selected}
+ onChange={handleSelectValue}
+ searchValue={search}
+ renderValue={renderValue}
+ onSearch={handleSearch}
+ isLoading={isLoading}
+ listProps={{ renderEmptyState: () => <Empty /> }}
+ allowSearch
+ >
+ {items?.map(({ value }) => {
+ return (
+ <ListItem key={value} id={value}>
+ {formatValue(value, type)}
+ </ListItem>
+ );
+ })}
+ </Select>
+ )}
+ </Grid>
+ <Column justifyContent="flex-start">
+ <Button onPress={() => onRemove?.(name)}>
+ <Icon>
+ <X />
+ </Icon>
+ </Button>
+ </Column>
+ </Grid>
+ </Column>
+ );
+}
diff --git a/src/components/common/GridRow.tsx b/src/components/common/GridRow.tsx
new file mode 100644
index 0000000..72f1db6
--- /dev/null
+++ b/src/components/common/GridRow.tsx
@@ -0,0 +1,32 @@
+import { Grid } from '@umami/react-zen';
+
+const LAYOUTS = {
+ one: { columns: '1fr' },
+ two: {
+ columns: {
+ xs: '1fr',
+ md: 'repeat(auto-fill, minmax(560px, 1fr))',
+ },
+ },
+ three: {
+ columns: {
+ xs: '1fr',
+ md: 'repeat(auto-fill, minmax(360px, 1fr))',
+ },
+ },
+ 'one-two': { columns: { xs: '1fr', md: 'repeat(3, 1fr)' } },
+ 'two-one': { columns: { xs: '1fr', md: 'repeat(3, 1fr)' } },
+};
+
+export function GridRow(props: {
+ layout?: 'one' | 'two' | 'three' | 'one-two' | 'two-one' | 'compare';
+ className?: string;
+ children?: any;
+}) {
+ const { layout = 'two', children, ...otherProps } = props;
+ return (
+ <Grid gap="3" {...LAYOUTS[layout]} {...otherProps}>
+ {children}
+ </Grid>
+ );
+}
diff --git a/src/components/common/LinkButton.tsx b/src/components/common/LinkButton.tsx
new file mode 100644
index 0000000..35292ba
--- /dev/null
+++ b/src/components/common/LinkButton.tsx
@@ -0,0 +1,41 @@
+import { Button, type ButtonProps } from '@umami/react-zen';
+import Link from 'next/link';
+import type { ReactNode } from 'react';
+import { useLocale } from '@/components/hooks';
+
+export interface LinkButtonProps extends ButtonProps {
+ href: string;
+ target?: string;
+ scroll?: boolean;
+ variant?: any;
+ prefetch?: boolean;
+ asAnchor?: boolean;
+ children?: ReactNode;
+}
+
+export function LinkButton({
+ href,
+ variant,
+ scroll = true,
+ target,
+ prefetch,
+ children,
+ asAnchor,
+ ...props
+}: LinkButtonProps) {
+ const { dir } = useLocale();
+
+ return (
+ <Button {...props} variant={variant} asChild>
+ {asAnchor ? (
+ <a href={href} target={target}>
+ {children}
+ </a>
+ ) : (
+ <Link href={href} dir={dir} scroll={scroll} target={target} prefetch={prefetch}>
+ {children}
+ </Link>
+ )}
+ </Button>
+ );
+}
diff --git a/src/components/common/LoadingPanel.tsx b/src/components/common/LoadingPanel.tsx
new file mode 100644
index 0000000..fb37e14
--- /dev/null
+++ b/src/components/common/LoadingPanel.tsx
@@ -0,0 +1,71 @@
+import { Column, type ColumnProps, Loading } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { Empty } from '@/components/common/Empty';
+import { ErrorMessage } from '@/components/common/ErrorMessage';
+
+export interface LoadingPanelProps extends ColumnProps {
+ data?: any;
+ error?: unknown;
+ isEmpty?: boolean;
+ isLoading?: boolean;
+ isFetching?: boolean;
+ loadingIcon?: 'dots' | 'spinner';
+ loadingPlacement?: 'center' | 'absolute' | 'inline';
+ renderEmpty?: () => ReactNode;
+ children: ReactNode;
+}
+
+export function LoadingPanel({
+ data,
+ error,
+ isEmpty,
+ isLoading,
+ isFetching,
+ loadingIcon = 'dots',
+ loadingPlacement = 'absolute',
+ renderEmpty = () => <Empty />,
+ children,
+ ...props
+}: LoadingPanelProps): ReactNode {
+ const empty = isEmpty ?? checkEmpty(data);
+
+ // Show loading spinner only if no data exists
+ if (isLoading || isFetching) {
+ return (
+ <Column position="relative" height="100%" width="100%" {...props}>
+ <Loading icon={loadingIcon} placement={loadingPlacement} />
+ </Column>
+ );
+ }
+
+ // Show error
+ if (error) {
+ return <ErrorMessage />;
+ }
+
+ // Show empty state (once loaded)
+ if (!error && !isLoading && !isFetching && empty) {
+ return renderEmpty();
+ }
+
+ // Show main content when data exists
+ if (!isLoading && !isFetching && !error && !empty) {
+ return children;
+ }
+
+ return null;
+}
+
+function checkEmpty(data: any) {
+ if (!data) return false;
+
+ if (Array.isArray(data)) {
+ return data.length <= 0;
+ }
+
+ if (typeof data === 'object') {
+ return Object.keys(data).length <= 0;
+ }
+
+ return !!data;
+}
diff --git a/src/components/common/PageBody.tsx b/src/components/common/PageBody.tsx
new file mode 100644
index 0000000..f07e589
--- /dev/null
+++ b/src/components/common/PageBody.tsx
@@ -0,0 +1,42 @@
+'use client';
+import { AlertBanner, Column, type ColumnProps, Loading } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { useMessages } from '@/components/hooks';
+
+const DEFAULT_WIDTH = '1320px';
+
+export function PageBody({
+ maxWidth = DEFAULT_WIDTH,
+ error,
+ isLoading,
+ children,
+ ...props
+}: {
+ maxWidth?: string;
+ error?: unknown;
+ isLoading?: boolean;
+ children?: ReactNode;
+} & ColumnProps) {
+ const { formatMessage, messages } = useMessages();
+
+ if (error) {
+ return <AlertBanner title={formatMessage(messages.error)} variant="error" />;
+ }
+
+ if (isLoading) {
+ return <Loading placement="absolute" />;
+ }
+
+ return (
+ <Column
+ {...props}
+ width="100%"
+ paddingBottom="6"
+ maxWidth={maxWidth}
+ paddingX={{ xs: '3', md: '6' }}
+ style={{ margin: '0 auto' }}
+ >
+ {children}
+ </Column>
+ );
+}
diff --git a/src/components/common/PageHeader.tsx b/src/components/common/PageHeader.tsx
new file mode 100644
index 0000000..9216788
--- /dev/null
+++ b/src/components/common/PageHeader.tsx
@@ -0,0 +1,58 @@
+import { Column, Grid, Heading, Icon, Row, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { LinkButton } from './LinkButton';
+
+export function PageHeader({
+ title,
+ description,
+ label,
+ icon,
+ showBorder = true,
+ titleHref,
+ children,
+}: {
+ title: string;
+ description?: string;
+ label?: ReactNode;
+ icon?: ReactNode;
+ showBorder?: boolean;
+ titleHref?: string;
+ allowEdit?: boolean;
+ className?: string;
+ children?: ReactNode;
+}) {
+ return (
+ <Grid
+ columns={{ xs: '1fr', md: '1fr 1fr' }}
+ paddingY="6"
+ marginBottom="6"
+ border={showBorder ? 'bottom' : undefined}
+ >
+ <Column gap="2">
+ {label}
+ <Row alignItems="center" gap="3">
+ {icon && (
+ <Icon size="md" color="muted">
+ {icon}
+ </Icon>
+ )}
+ {title && titleHref ? (
+ <LinkButton href={titleHref} variant="quiet">
+ <Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading>
+ </LinkButton>
+ ) : (
+ title && <Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading>
+ )}
+ </Row>
+ {description && (
+ <Text color="muted" truncate style={{ maxWidth: 600 }} title={description}>
+ {description}
+ </Text>
+ )}
+ </Column>
+ <Row justifyContent="flex-end" alignItems="center">
+ {children}
+ </Row>
+ </Grid>
+ );
+}
diff --git a/src/components/common/Pager.tsx b/src/components/common/Pager.tsx
new file mode 100644
index 0000000..c65e2f6
--- /dev/null
+++ b/src/components/common/Pager.tsx
@@ -0,0 +1,60 @@
+import { Button, Icon, Row, Text } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+import { ChevronRight } from '@/components/icons';
+
+export interface PagerProps {
+ page: string | number;
+ pageSize: string | number;
+ count: string | number;
+ onPageChange: (nextPage: number) => void;
+ className?: string;
+}
+
+export function Pager({ page, pageSize, count, onPageChange }: PagerProps) {
+ const { formatMessage, labels } = useMessages();
+ const maxPage = pageSize && count ? Math.ceil(+count / +pageSize) : 0;
+ const lastPage = page === maxPage;
+ const firstPage = page === 1;
+
+ if (count === 0 || !maxPage) {
+ return null;
+ }
+
+ const handlePageChange = (value: number) => {
+ const nextPage = +page + +value;
+
+ if (nextPage > 0 && nextPage <= maxPage) {
+ onPageChange(nextPage);
+ }
+ };
+
+ if (maxPage === 1) {
+ return null;
+ }
+
+ return (
+ <Row alignItems="center" justifyContent="space-between" gap="3" flexGrow={1}>
+ <Text>{formatMessage(labels.numberOfRecords, { x: count.toLocaleString() })}</Text>
+ <Row alignItems="center" justifyContent="flex-end" gap="3">
+ <Text>
+ {formatMessage(labels.pageOf, {
+ current: page.toLocaleString(),
+ total: maxPage.toLocaleString(),
+ })}
+ </Text>
+ <Row gap="1">
+ <Button variant="outline" onPress={() => handlePageChange(-1)} isDisabled={firstPage}>
+ <Icon size="sm" rotate={180}>
+ <ChevronRight />
+ </Icon>
+ </Button>
+ <Button variant="outline" onPress={() => handlePageChange(1)} isDisabled={lastPage}>
+ <Icon size="sm">
+ <ChevronRight />
+ </Icon>
+ </Button>
+ </Row>
+ </Row>
+ </Row>
+ );
+}
diff --git a/src/components/common/Panel.tsx b/src/components/common/Panel.tsx
new file mode 100644
index 0000000..bb66746
--- /dev/null
+++ b/src/components/common/Panel.tsx
@@ -0,0 +1,64 @@
+import {
+ Button,
+ Column,
+ type ColumnProps,
+ Heading,
+ Icon,
+ Row,
+ Tooltip,
+ TooltipTrigger,
+} from '@umami/react-zen';
+import { useState } from 'react';
+import { useMessages } from '@/components/hooks';
+import { Maximize, X } from '@/components/icons';
+
+export interface PanelProps extends ColumnProps {
+ title?: string;
+ allowFullscreen?: boolean;
+}
+
+const fullscreenStyles = {
+ position: 'fixed',
+ width: '100%',
+ height: '100%',
+ top: 0,
+ left: 0,
+ border: 'none',
+ zIndex: 9999,
+} as any;
+
+export function Panel({ title, allowFullscreen, style, children, ...props }: PanelProps) {
+ const { formatMessage, labels } = useMessages();
+ const [isFullscreen, setIsFullscreen] = useState(false);
+
+ const handleFullscreen = () => {
+ setIsFullscreen(!isFullscreen);
+ };
+
+ return (
+ <Column
+ paddingY="6"
+ paddingX={{ xs: '3', md: '6' }}
+ border
+ borderRadius="3"
+ backgroundColor
+ position="relative"
+ gap
+ {...props}
+ style={{ ...style, ...(isFullscreen ? fullscreenStyles : {}) }}
+ >
+ {title && <Heading>{title}</Heading>}
+ {allowFullscreen && (
+ <Row justifyContent="flex-end" alignItems="center">
+ <TooltipTrigger delay={0} isDisabled={isFullscreen}>
+ <Button size="sm" variant="quiet" onPress={handleFullscreen}>
+ <Icon>{isFullscreen ? <X /> : <Maximize />}</Icon>
+ </Button>
+ <Tooltip>{formatMessage(labels.maximize)}</Tooltip>
+ </TooltipTrigger>
+ </Row>
+ )}
+ {children}
+ </Column>
+ );
+}
diff --git a/src/components/common/SectionHeader.tsx b/src/components/common/SectionHeader.tsx
new file mode 100644
index 0000000..5b911ef
--- /dev/null
+++ b/src/components/common/SectionHeader.tsx
@@ -0,0 +1,28 @@
+import { Heading, Icon, Row, type RowProps, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+
+export function SectionHeader({
+ title,
+ description,
+ icon,
+ children,
+ ...props
+}: {
+ title?: string;
+ description?: string;
+ icon?: ReactNode;
+ allowEdit?: boolean;
+ className?: string;
+ children?: ReactNode;
+} & RowProps) {
+ return (
+ <Row {...props} justifyContent="space-between" alignItems="center" height="60px">
+ <Row gap="3" alignItems="center">
+ {icon && <Icon size="md">{icon}</Icon>}
+ {title && <Heading size="3">{title}</Heading>}
+ {description && <Text color="muted">{description}</Text>}
+ </Row>
+ <Row justifyContent="flex-end">{children}</Row>
+ </Row>
+ );
+}
diff --git a/src/components/common/SideMenu.tsx b/src/components/common/SideMenu.tsx
new file mode 100644
index 0000000..92ff798
--- /dev/null
+++ b/src/components/common/SideMenu.tsx
@@ -0,0 +1,80 @@
+import {
+ Column,
+ Heading,
+ IconLabel,
+ NavMenu,
+ NavMenuGroup,
+ NavMenuItem,
+ type NavMenuProps,
+ Row,
+} from '@umami/react-zen';
+import Link from 'next/link';
+
+interface SideMenuData {
+ id: string;
+ label: string;
+ icon?: any;
+ path: string;
+}
+
+interface SideMenuItems {
+ label?: string;
+ items: SideMenuData[];
+}
+
+export interface SideMenuProps extends NavMenuProps {
+ items: SideMenuItems[];
+ title?: string;
+ selectedKey?: string;
+ allowMinimize?: boolean;
+}
+
+export function SideMenu({
+ items = [],
+ title,
+ selectedKey,
+ allowMinimize,
+ ...props
+}: SideMenuProps) {
+ const renderItems = (items: SideMenuData[]) => {
+ return items?.map(({ id, label, icon, path }) => {
+ const isSelected = selectedKey === id;
+
+ return (
+ <Link key={id} href={path}>
+ <NavMenuItem isSelected={isSelected}>
+ <IconLabel icon={icon}>{label}</IconLabel>
+ </NavMenuItem>
+ </Link>
+ );
+ });
+ };
+
+ return (
+ <Column gap overflowY="auto" justifyContent="space-between" position="sticky" top="20px">
+ {title && (
+ <Row padding>
+ <Heading size="1">{title}</Heading>
+ </Row>
+ )}
+ <NavMenu gap="6" {...props}>
+ {items?.map(({ label, items }, index) => {
+ if (label) {
+ return (
+ <NavMenuGroup
+ title={label}
+ key={`${label}${index}`}
+ gap="1"
+ allowMinimize={allowMinimize}
+ marginBottom="3"
+ >
+ {renderItems(items)}
+ </NavMenuGroup>
+ );
+ }
+ return null;
+ })}
+ </NavMenu>
+ </Column>
+ );
+}
diff --git a/src/components/common/TypeConfirmationForm.tsx b/src/components/common/TypeConfirmationForm.tsx
new file mode 100644
index 0000000..1121fa7
--- /dev/null
+++ b/src/components/common/TypeConfirmationForm.tsx
@@ -0,0 +1,55 @@
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormField,
+ FormSubmitButton,
+ TextField,
+} from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+
+export function TypeConfirmationForm({
+ confirmationValue,
+ buttonLabel,
+ buttonVariant,
+ isLoading,
+ error,
+ onConfirm,
+ onClose,
+}: {
+ confirmationValue: string;
+ buttonLabel?: string;
+ buttonVariant?: 'primary' | 'outline' | 'quiet' | 'danger' | 'zero';
+ isLoading?: boolean;
+ error?: string | Error;
+ onConfirm?: () => void;
+ onClose?: () => void;
+}) {
+ const { formatMessage, labels, messages, getErrorMessage } = useMessages();
+ if (!confirmationValue) {
+ return null;
+ }
+
+ return (
+ <Form onSubmit={onConfirm} error={getErrorMessage(error)}>
+ <p>
+ {formatMessage(messages.actionConfirmation, {
+ confirmation: confirmationValue,
+ })}
+ </p>
+ <FormField
+ label={formatMessage(labels.confirm)}
+ name="confirm"
+ rules={{ validate: value => value === confirmationValue }}
+ >
+ <TextField autoComplete="off" />
+ </FormField>
+ <FormButtons>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <FormSubmitButton isLoading={isLoading} variant={buttonVariant}>
+ {buttonLabel || formatMessage(labels.ok)}
+ </FormSubmitButton>
+ </FormButtons>
+ </Form>
+ );
+}
diff --git a/src/components/common/TypeIcon.tsx b/src/components/common/TypeIcon.tsx
new file mode 100644
index 0000000..8894b3a
--- /dev/null
+++ b/src/components/common/TypeIcon.tsx
@@ -0,0 +1,29 @@
+import { Row } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+
+export function TypeIcon({
+ type,
+ value,
+ children,
+}: {
+ type: 'browser' | 'country' | 'device' | 'os';
+ value: string;
+ children?: ReactNode;
+}) {
+ return (
+ <Row gap="3" alignItems="center">
+ <img
+ src={`${process.env.basePath || ''}/images/${type}/${
+ value?.replaceAll(' ', '-').toLowerCase() || 'unknown'
+ }.png`}
+ onError={e => {
+ e.currentTarget.src = `${process.env.basePath || ''}/images/${type}/unknown.png`;
+ }}
+ alt={value}
+ width={type === 'country' ? undefined : 16}
+ height={type === 'country' ? undefined : 16}
+ />
+ {children}
+ </Row>
+ );
+}
diff --git a/src/components/hooks/context/useLink.ts b/src/components/hooks/context/useLink.ts
new file mode 100644
index 0000000..8766bbb
--- /dev/null
+++ b/src/components/hooks/context/useLink.ts
@@ -0,0 +1,6 @@
+import { useContext } from 'react';
+import { LinkContext } from '@/app/(main)/links/LinkProvider';
+
+export function useLink() {
+ return useContext(LinkContext);
+}
diff --git a/src/components/hooks/context/usePixel.ts b/src/components/hooks/context/usePixel.ts
new file mode 100644
index 0000000..69cad6f
--- /dev/null
+++ b/src/components/hooks/context/usePixel.ts
@@ -0,0 +1,6 @@
+import { useContext } from 'react';
+import { PixelContext } from '@/app/(main)/pixels/PixelProvider';
+
+export function usePixel() {
+ return useContext(PixelContext);
+}
diff --git a/src/components/hooks/context/useTeam.ts b/src/components/hooks/context/useTeam.ts
new file mode 100644
index 0000000..95ff4be
--- /dev/null
+++ b/src/components/hooks/context/useTeam.ts
@@ -0,0 +1,6 @@
+import { useContext } from 'react';
+import { TeamContext } from '@/app/(main)/teams/TeamProvider';
+
+export function useTeam() {
+ return useContext(TeamContext);
+}
diff --git a/src/components/hooks/context/useUser.ts b/src/components/hooks/context/useUser.ts
new file mode 100644
index 0000000..fa97ea9
--- /dev/null
+++ b/src/components/hooks/context/useUser.ts
@@ -0,0 +1,6 @@
+import { useContext } from 'react';
+import { UserContext } from '@/app/(main)/admin/users/[userId]/UserProvider';
+
+export function useUser() {
+ return useContext(UserContext);
+}
diff --git a/src/components/hooks/context/useWebsite.ts b/src/components/hooks/context/useWebsite.ts
new file mode 100644
index 0000000..3d4be27
--- /dev/null
+++ b/src/components/hooks/context/useWebsite.ts
@@ -0,0 +1,6 @@
+import { useContext } from 'react';
+import { WebsiteContext } from '@/app/(main)/websites/WebsiteProvider';
+
+export function useWebsite() {
+ return useContext(WebsiteContext);
+}
diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts
new file mode 100644
index 0000000..e8e5c13
--- /dev/null
+++ b/src/components/hooks/index.ts
@@ -0,0 +1,84 @@
+'use client';
+
+// Context hooks
+export * from './context/useLink';
+export * from './context/usePixel';
+export * from './context/useTeam';
+export * from './context/useUser';
+export * from './context/useWebsite';
+
+// Query hooks
+export * from './queries/useActiveUsersQuery';
+export * from './queries/useDateRangeQuery';
+export * from './queries/useDeleteQuery';
+export * from './queries/useEventDataEventsQuery';
+export * from './queries/useEventDataPropertiesQuery';
+export * from './queries/useEventDataQuery';
+export * from './queries/useEventDataValuesQuery';
+export * from './queries/useLinkQuery';
+export * from './queries/useLinksQuery';
+export * from './queries/useLoginQuery';
+export * from './queries/usePixelQuery';
+export * from './queries/usePixelsQuery';
+export * from './queries/useRealtimeQuery';
+export * from './queries/useReportQuery';
+export * from './queries/useReportsQuery';
+export * from './queries/useResultQuery';
+export * from './queries/useSessionActivityQuery';
+export * from './queries/useSessionDataPropertiesQuery';
+export * from './queries/useSessionDataQuery';
+export * from './queries/useSessionDataValuesQuery';
+export * from './queries/useShareTokenQuery';
+export * from './queries/useTeamMembersQuery';
+export * from './queries/useTeamQuery';
+export * from './queries/useTeamsQuery';
+export * from './queries/useTeamWebsitesQuery';
+export * from './queries/useUpdateQuery';
+export * from './queries/useUserQuery';
+export * from './queries/useUsersQuery';
+export * from './queries/useUserTeamsQuery';
+export * from './queries/useUserWebsitesQuery';
+export * from './queries/useWebsiteCohortQuery';
+export * from './queries/useWebsiteCohortsQuery';
+export * from './queries/useWebsiteEventsQuery';
+export * from './queries/useWebsiteEventsSeriesQuery';
+export * from './queries/useWebsiteExpandedMetricsQuery';
+export * from './queries/useWebsiteMetricsQuery';
+export * from './queries/useWebsitePageviewsQuery';
+export * from './queries/useWebsiteQuery';
+export * from './queries/useWebsiteSegmentQuery';
+export * from './queries/useWebsiteSegmentsQuery';
+export * from './queries/useWebsiteSessionQuery';
+export * from './queries/useWebsiteSessionStatsQuery';
+export * from './queries/useWebsiteSessionsQuery';
+export * from './queries/useWebsiteStatsQuery';
+export * from './queries/useWebsitesQuery';
+export * from './queries/useWebsiteValuesQuery';
+export * from './queries/useWeeklyTrafficQuery';
+
+// Regular hooks
+export * from './useApi';
+export * from './useConfig';
+export * from './useCountryNames';
+export * from './useDateParameters';
+export * from './useDateRange';
+export * from './useDocumentClick';
+export * from './useEscapeKey';
+export * from './useFields';
+export * from './useFilterParameters';
+export * from './useFilters';
+export * from './useForceUpdate';
+export * from './useFormat';
+export * from './useGlobalState';
+export * from './useLanguageNames';
+export * from './useLocale';
+export * from './useMessages';
+export * from './useMobile';
+export * from './useModified';
+export * from './useNavigation';
+export * from './usePagedQuery';
+export * from './usePageParameters';
+export * from './useRegionNames';
+export * from './useSlug';
+export * from './useSticky';
+export * from './useTimezone';
diff --git a/src/components/hooks/queries/useActiveUsersQuery.ts b/src/components/hooks/queries/useActiveUsersQuery.ts
new file mode 100644
index 0000000..42867c1
--- /dev/null
+++ b/src/components/hooks/queries/useActiveUsersQuery.ts
@@ -0,0 +1,12 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+
+export function useActyiveUsersQuery(websiteId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ return useQuery<any>({
+ queryKey: ['websites:active', websiteId],
+ queryFn: () => get(`/websites/${websiteId}/active`),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useDateRangeQuery.ts b/src/components/hooks/queries/useDateRangeQuery.ts
new file mode 100644
index 0000000..84b7eec
--- /dev/null
+++ b/src/components/hooks/queries/useDateRangeQuery.ts
@@ -0,0 +1,23 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+
+type DateRange = {
+ startDate?: string;
+ endDate?: string;
+};
+
+export function useDateRangeQuery(websiteId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+
+ const { data } = useQuery<DateRange>({
+ queryKey: ['date-range', websiteId],
+ queryFn: () => get(`/websites/${websiteId}/daterange`),
+ enabled: !!websiteId,
+ ...options,
+ });
+
+ return {
+ startDate: data?.startDate ? new Date(data.startDate) : null,
+ endDate: data?.endDate ? new Date(data.endDate) : null,
+ };
+}
diff --git a/src/components/hooks/queries/useDeleteQuery.ts b/src/components/hooks/queries/useDeleteQuery.ts
new file mode 100644
index 0000000..556231a
--- /dev/null
+++ b/src/components/hooks/queries/useDeleteQuery.ts
@@ -0,0 +1,12 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useDeleteQuery(path: string, params?: Record<string, any>) {
+ const { del, useMutation } = useApi();
+ const query = useMutation({
+ mutationFn: () => del(path, params),
+ });
+ const { touch } = useModified();
+
+ return { ...query, touch };
+}
diff --git a/src/components/hooks/queries/useEventDataEventsQuery.ts b/src/components/hooks/queries/useEventDataEventsQuery.ts
new file mode 100644
index 0000000..1401989
--- /dev/null
+++ b/src/components/hooks/queries/useEventDataEventsQuery.ts
@@ -0,0 +1,27 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useEventDataEventsQuery(websiteId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery({
+ queryKey: [
+ 'websites:event-data:events',
+ { websiteId, startAt, endAt, unit, timezone, ...filters },
+ ],
+ queryFn: () =>
+ get(`/websites/${websiteId}/event-data/events`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useEventDataPropertiesQuery.ts b/src/components/hooks/queries/useEventDataPropertiesQuery.ts
new file mode 100644
index 0000000..dfa6e92
--- /dev/null
+++ b/src/components/hooks/queries/useEventDataPropertiesQuery.ts
@@ -0,0 +1,27 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useEventDataPropertiesQuery(websiteId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<any>({
+ queryKey: [
+ 'websites:event-data:properties',
+ { websiteId, startAt, endAt, unit, timezone, ...filters },
+ ],
+ queryFn: () =>
+ get(`/websites/${websiteId}/event-data/properties`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useEventDataQuery.ts b/src/components/hooks/queries/useEventDataQuery.ts
new file mode 100644
index 0000000..2ccbd63
--- /dev/null
+++ b/src/components/hooks/queries/useEventDataQuery.ts
@@ -0,0 +1,27 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useEventDataQuery(websiteId: string, eventId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const params = useFilterParameters();
+
+ return useQuery({
+ queryKey: [
+ 'websites:event-data',
+ { websiteId, eventId, startAt, endAt, unit, timezone, ...params },
+ ],
+ queryFn: () =>
+ get(`/websites/${websiteId}/event-data/${eventId}`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...params,
+ }),
+ enabled: !!(websiteId && eventId),
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useEventDataValuesQuery.ts b/src/components/hooks/queries/useEventDataValuesQuery.ts
new file mode 100644
index 0000000..6529e14
--- /dev/null
+++ b/src/components/hooks/queries/useEventDataValuesQuery.ts
@@ -0,0 +1,34 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useEventDataValuesQuery(
+ websiteId: string,
+ event: string,
+ propertyName: string,
+ options?: ReactQueryOptions,
+) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<any>({
+ queryKey: [
+ 'websites:event-data:values',
+ { websiteId, event, propertyName, startAt, endAt, unit, timezone, ...filters },
+ ],
+ queryFn: () =>
+ get(`/websites/${websiteId}/event-data/values`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ event,
+ propertyName,
+ }),
+ enabled: !!(websiteId && propertyName),
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useLinkQuery.ts b/src/components/hooks/queries/useLinkQuery.ts
new file mode 100644
index 0000000..2a5d4a9
--- /dev/null
+++ b/src/components/hooks/queries/useLinkQuery.ts
@@ -0,0 +1,15 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useLinkQuery(linkId: string) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`link:${linkId}`);
+
+ return useQuery({
+ queryKey: ['link', { linkId, modified }],
+ queryFn: () => {
+ return get(`/links/${linkId}`);
+ },
+ enabled: !!linkId,
+ });
+}
diff --git a/src/components/hooks/queries/useLinksQuery.ts b/src/components/hooks/queries/useLinksQuery.ts
new file mode 100644
index 0000000..ebf945f
--- /dev/null
+++ b/src/components/hooks/queries/useLinksQuery.ts
@@ -0,0 +1,17 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useLinksQuery({ teamId }: { teamId?: string }, options?: ReactQueryOptions) {
+ const { modified } = useModified('links');
+ const { get } = useApi();
+
+ return usePagedQuery({
+ queryKey: ['links', { teamId, modified }],
+ queryFn: pageParams => {
+ return get(teamId ? `/teams/${teamId}/links` : '/links', pageParams);
+ },
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useLoginQuery.ts b/src/components/hooks/queries/useLoginQuery.ts
new file mode 100644
index 0000000..a64b784
--- /dev/null
+++ b/src/components/hooks/queries/useLoginQuery.ts
@@ -0,0 +1,23 @@
+import { setUser, useApp } from '@/store/app';
+import { useApi } from '../useApi';
+
+const selector = (state: { user: any }) => state.user;
+
+export function useLoginQuery() {
+ const { post, useQuery } = useApi();
+ const user = useApp(selector);
+
+ const query = useQuery({
+ queryKey: ['login'],
+ queryFn: async () => {
+ const data = await post('/auth/verify');
+
+ setUser(data);
+
+ return data;
+ },
+ enabled: !user,
+ });
+
+ return { user, setUser, ...query };
+}
diff --git a/src/components/hooks/queries/usePixelQuery.ts b/src/components/hooks/queries/usePixelQuery.ts
new file mode 100644
index 0000000..7fd83c2
--- /dev/null
+++ b/src/components/hooks/queries/usePixelQuery.ts
@@ -0,0 +1,15 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function usePixelQuery(pixelId: string) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`pixel:${pixelId}`);
+
+ return useQuery({
+ queryKey: ['pixel', { pixelId, modified }],
+ queryFn: () => {
+ return get(`/pixels/${pixelId}`);
+ },
+ enabled: !!pixelId,
+ });
+}
diff --git a/src/components/hooks/queries/usePixelsQuery.ts b/src/components/hooks/queries/usePixelsQuery.ts
new file mode 100644
index 0000000..c431179
--- /dev/null
+++ b/src/components/hooks/queries/usePixelsQuery.ts
@@ -0,0 +1,17 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function usePixelsQuery({ teamId }: { teamId?: string }, options?: ReactQueryOptions) {
+ const { modified } = useModified('pixels');
+ const { get } = useApi();
+
+ return usePagedQuery({
+ queryKey: ['pixels', { teamId, modified }],
+ queryFn: pageParams => {
+ return get(teamId ? `/teams/${teamId}/pixels` : '/pixels', pageParams);
+ },
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useRealtimeQuery.ts b/src/components/hooks/queries/useRealtimeQuery.ts
new file mode 100644
index 0000000..1a5bd1c
--- /dev/null
+++ b/src/components/hooks/queries/useRealtimeQuery.ts
@@ -0,0 +1,17 @@
+import { REALTIME_INTERVAL } from '@/lib/constants';
+import type { RealtimeData } from '@/lib/types';
+import { useApi } from '../useApi';
+
+export function useRealtimeQuery(websiteId: string) {
+ const { get, useQuery } = useApi();
+ const { data, isLoading, error } = useQuery<RealtimeData>({
+ queryKey: ['realtime', { websiteId }],
+ queryFn: async () => {
+ return get(`/realtime/${websiteId}`);
+ },
+ enabled: !!websiteId,
+ refetchInterval: REALTIME_INTERVAL,
+ });
+
+ return { data, isLoading, error };
+}
diff --git a/src/components/hooks/queries/useReportQuery.ts b/src/components/hooks/queries/useReportQuery.ts
new file mode 100644
index 0000000..6973e2d
--- /dev/null
+++ b/src/components/hooks/queries/useReportQuery.ts
@@ -0,0 +1,15 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useReportQuery(reportId: string) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`report:${reportId}`);
+
+ return useQuery({
+ queryKey: ['report', { reportId, modified }],
+ queryFn: () => {
+ return get(`/reports/${reportId}`);
+ },
+ enabled: !!reportId,
+ });
+}
diff --git a/src/components/hooks/queries/useReportsQuery.ts b/src/components/hooks/queries/useReportsQuery.ts
new file mode 100644
index 0000000..ba1bdd4
--- /dev/null
+++ b/src/components/hooks/queries/useReportsQuery.ts
@@ -0,0 +1,19 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useReportsQuery(
+ { websiteId, type }: { websiteId: string; type?: string },
+ options?: ReactQueryOptions,
+) {
+ const { modified } = useModified(`reports:${type}`);
+ const { get } = useApi();
+
+ return usePagedQuery({
+ queryKey: ['reports', { websiteId, type, modified }],
+ queryFn: async () => get('/reports', { websiteId, type }),
+ enabled: !!websiteId && !!type,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useResultQuery.ts b/src/components/hooks/queries/useResultQuery.ts
new file mode 100644
index 0000000..c6fce12
--- /dev/null
+++ b/src/components/hooks/queries/useResultQuery.ts
@@ -0,0 +1,44 @@
+import { useDateParameters } from '@/components/hooks/useDateParameters';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useResultQuery<T = any>(
+ type: string,
+ params?: Record<string, any>,
+ options?: ReactQueryOptions<T>,
+) {
+ const { websiteId, ...parameters } = params;
+ const { post, useQuery } = useApi();
+ const { startDate, endDate, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<T>({
+ queryKey: [
+ 'reports',
+ {
+ type,
+ websiteId,
+ startDate,
+ endDate,
+ timezone,
+ ...params,
+ ...filters,
+ },
+ ],
+ queryFn: () =>
+ post(`/reports/${type}`, {
+ websiteId,
+ type,
+ filters,
+ parameters: {
+ startDate,
+ endDate,
+ timezone,
+ ...parameters,
+ },
+ }),
+ enabled: !!type,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useSessionActivityQuery.ts b/src/components/hooks/queries/useSessionActivityQuery.ts
new file mode 100644
index 0000000..d8d34ac
--- /dev/null
+++ b/src/components/hooks/queries/useSessionActivityQuery.ts
@@ -0,0 +1,21 @@
+import { useApi } from '../useApi';
+
+export function useSessionActivityQuery(
+ websiteId: string,
+ sessionId: string,
+ startDate: Date,
+ endDate: Date,
+) {
+ const { get, useQuery } = useApi();
+
+ return useQuery({
+ queryKey: ['session:activity', { websiteId, sessionId, startDate, endDate }],
+ queryFn: () => {
+ return get(`/websites/${websiteId}/sessions/${sessionId}/activity`, {
+ startAt: +new Date(startDate),
+ endAt: +new Date(endDate),
+ });
+ },
+ enabled: Boolean(websiteId && sessionId && startDate && endDate),
+ });
+}
diff --git a/src/components/hooks/queries/useSessionDataPropertiesQuery.ts b/src/components/hooks/queries/useSessionDataPropertiesQuery.ts
new file mode 100644
index 0000000..ac651bb
--- /dev/null
+++ b/src/components/hooks/queries/useSessionDataPropertiesQuery.ts
@@ -0,0 +1,27 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useSessionDataPropertiesQuery(websiteId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<any>({
+ queryKey: [
+ 'websites:session-data:properties',
+ { websiteId, startAt, endAt, unit, timezone, ...filters },
+ ],
+ queryFn: () =>
+ get(`/websites/${websiteId}/session-data/properties`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useSessionDataQuery.ts b/src/components/hooks/queries/useSessionDataQuery.ts
new file mode 100644
index 0000000..62b5398
--- /dev/null
+++ b/src/components/hooks/queries/useSessionDataQuery.ts
@@ -0,0 +1,12 @@
+import { useApi } from '../useApi';
+
+export function useSessionDataQuery(websiteId: string, sessionId: string) {
+ const { get, useQuery } = useApi();
+
+ return useQuery({
+ queryKey: ['session:data', { websiteId, sessionId }],
+ queryFn: () => {
+ return get(`/websites/${websiteId}/sessions/${sessionId}/properties`, { websiteId });
+ },
+ });
+}
diff --git a/src/components/hooks/queries/useSessionDataValuesQuery.ts b/src/components/hooks/queries/useSessionDataValuesQuery.ts
new file mode 100644
index 0000000..d5e180b
--- /dev/null
+++ b/src/components/hooks/queries/useSessionDataValuesQuery.ts
@@ -0,0 +1,32 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useSessionDataValuesQuery(
+ websiteId: string,
+ propertyName: string,
+ options?: ReactQueryOptions,
+) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<any>({
+ queryKey: [
+ 'websites:session-data:values',
+ { websiteId, propertyName, startAt, endAt, unit, timezone, ...filters },
+ ],
+ queryFn: () =>
+ get(`/websites/${websiteId}/session-data/values`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ propertyName,
+ }),
+ enabled: !!(websiteId && propertyName),
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useShareTokenQuery.ts b/src/components/hooks/queries/useShareTokenQuery.ts
new file mode 100644
index 0000000..dbad3dc
--- /dev/null
+++ b/src/components/hooks/queries/useShareTokenQuery.ts
@@ -0,0 +1,25 @@
+import { setShareToken, useApp } from '@/store/app';
+import { useApi } from '../useApi';
+
+const selector = (state: { shareToken: string }) => state.shareToken;
+
+export function useShareTokenQuery(shareId: string): {
+ shareToken: any;
+ isLoading?: boolean;
+ error?: Error;
+} {
+ const shareToken = useApp(selector);
+ const { get, useQuery } = useApi();
+ const { isLoading, error } = useQuery({
+ queryKey: ['share', shareId],
+ queryFn: async () => {
+ const data = await get(`/share/${shareId}`);
+
+ setShareToken(data);
+
+ return data;
+ },
+ });
+
+ return { shareToken, isLoading, error };
+}
diff --git a/src/components/hooks/queries/useTeamMembersQuery.ts b/src/components/hooks/queries/useTeamMembersQuery.ts
new file mode 100644
index 0000000..6f6f815
--- /dev/null
+++ b/src/components/hooks/queries/useTeamMembersQuery.ts
@@ -0,0 +1,16 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useTeamMembersQuery(teamId: string) {
+ const { get } = useApi();
+ const { modified } = useModified(`teams:members`);
+
+ return usePagedQuery({
+ queryKey: ['teams:members', { teamId, modified }],
+ queryFn: (params: any) => {
+ return get(`/teams/${teamId}/users`, params);
+ },
+ enabled: !!teamId,
+ });
+}
diff --git a/src/components/hooks/queries/useTeamQuery.ts b/src/components/hooks/queries/useTeamQuery.ts
new file mode 100644
index 0000000..c076a6a
--- /dev/null
+++ b/src/components/hooks/queries/useTeamQuery.ts
@@ -0,0 +1,17 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useTeamQuery(teamId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`teams:${teamId}`);
+
+ return useQuery({
+ queryKey: ['teams', { teamId, modified }],
+ queryFn: () => get(`/teams/${teamId}`),
+ enabled: !!teamId,
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useTeamWebsitesQuery.ts b/src/components/hooks/queries/useTeamWebsitesQuery.ts
new file mode 100644
index 0000000..ffe601b
--- /dev/null
+++ b/src/components/hooks/queries/useTeamWebsitesQuery.ts
@@ -0,0 +1,15 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useTeamWebsitesQuery(teamId: string) {
+ const { get } = useApi();
+ const { modified } = useModified(`websites`);
+
+ return usePagedQuery({
+ queryKey: ['teams:websites', { teamId, modified }],
+ queryFn: (params: any) => {
+ return get(`/teams/${teamId}/websites`, params);
+ },
+ });
+}
diff --git a/src/components/hooks/queries/useTeamsQuery.ts b/src/components/hooks/queries/useTeamsQuery.ts
new file mode 100644
index 0000000..f1a09f4
--- /dev/null
+++ b/src/components/hooks/queries/useTeamsQuery.ts
@@ -0,0 +1,20 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useTeamsQuery(params?: Record<string, any>, options?: ReactQueryOptions) {
+ const { get } = useApi();
+ const { modified } = useModified(`teams`);
+
+ return usePagedQuery({
+ queryKey: ['teams:admin', { modified, ...params }],
+ queryFn: pageParams => {
+ return get(`/admin/teams`, {
+ ...pageParams,
+ ...params,
+ });
+ },
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useUpdateQuery.ts b/src/components/hooks/queries/useUpdateQuery.ts
new file mode 100644
index 0000000..85a9442
--- /dev/null
+++ b/src/components/hooks/queries/useUpdateQuery.ts
@@ -0,0 +1,15 @@
+import { useToast } from '@umami/react-zen';
+import type { ApiError } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useUpdateQuery(path: string, params?: Record<string, any>) {
+ const { post, useMutation } = useApi();
+ const query = useMutation<any, ApiError, Record<string, any>>({
+ mutationFn: (data: Record<string, any>) => post(path, { ...data, ...params }),
+ });
+ const { touch } = useModified();
+ const { toast } = useToast();
+
+ return { ...query, touch, toast };
+}
diff --git a/src/components/hooks/queries/useUserQuery.ts b/src/components/hooks/queries/useUserQuery.ts
new file mode 100644
index 0000000..07e23f0
--- /dev/null
+++ b/src/components/hooks/queries/useUserQuery.ts
@@ -0,0 +1,17 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useUserQuery(userId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`user:${userId}`);
+
+ return useQuery({
+ queryKey: ['users', { userId, modified }],
+ queryFn: () => get(`/users/${userId}`),
+ enabled: !!userId,
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useUserTeamsQuery.ts b/src/components/hooks/queries/useUserTeamsQuery.ts
new file mode 100644
index 0000000..82f6549
--- /dev/null
+++ b/src/components/hooks/queries/useUserTeamsQuery.ts
@@ -0,0 +1,15 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useUserTeamsQuery(userId: string) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`teams`);
+
+ return useQuery({
+ queryKey: ['teams', { userId, modified }],
+ queryFn: () => {
+ return get(`/users/${userId}/teams`);
+ },
+ enabled: !!userId,
+ });
+}
diff --git a/src/components/hooks/queries/useUserWebsitesQuery.ts b/src/components/hooks/queries/useUserWebsitesQuery.ts
new file mode 100644
index 0000000..f98eaff
--- /dev/null
+++ b/src/components/hooks/queries/useUserWebsitesQuery.ts
@@ -0,0 +1,31 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useUserWebsitesQuery(
+ { userId, teamId }: { userId?: string; teamId?: string },
+ params?: Record<string, any>,
+ options?: ReactQueryOptions,
+) {
+ const { get } = useApi();
+ const { modified } = useModified(`websites`);
+
+ return usePagedQuery({
+ queryKey: ['websites', { userId, teamId, modified, ...params }],
+ queryFn: pageParams => {
+ return get(
+ teamId
+ ? `/teams/${teamId}/websites`
+ : userId
+ ? `/users/${userId}/websites`
+ : '/me/websites',
+ {
+ ...pageParams,
+ ...params,
+ },
+ );
+ },
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useUsersQuery.ts b/src/components/hooks/queries/useUsersQuery.ts
new file mode 100644
index 0000000..d87900b
--- /dev/null
+++ b/src/components/hooks/queries/useUsersQuery.ts
@@ -0,0 +1,17 @@
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useUsersQuery() {
+ const { get } = useApi();
+ const { modified } = useModified(`users`);
+
+ return usePagedQuery({
+ queryKey: ['users:admin', { modified }],
+ queryFn: (pageParams: any) => {
+ return get('/admin/users', {
+ ...pageParams,
+ });
+ },
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteCohortQuery.ts b/src/components/hooks/queries/useWebsiteCohortQuery.ts
new file mode 100644
index 0000000..975766e
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteCohortQuery.ts
@@ -0,0 +1,21 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useWebsiteCohortQuery(
+ websiteId: string,
+ cohortId: string,
+ options?: ReactQueryOptions,
+) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`cohorts`);
+
+ return useQuery({
+ queryKey: ['website:cohorts', { websiteId, cohortId, modified }],
+ queryFn: () => get(`/websites/${websiteId}/segments/${cohortId}`),
+ enabled: !!(websiteId && cohortId),
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteCohortsQuery.ts b/src/components/hooks/queries/useWebsiteCohortsQuery.ts
new file mode 100644
index 0000000..e0cbf4c
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteCohortsQuery.ts
@@ -0,0 +1,25 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import { useFilterParameters } from '@/components/hooks/useFilterParameters';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useWebsiteCohortsQuery(
+ websiteId: string,
+ params?: Record<string, string>,
+ options?: ReactQueryOptions,
+) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`cohorts`);
+ const filters = useFilterParameters();
+
+ return useQuery({
+ queryKey: ['website:cohorts', { websiteId, modified, ...filters, ...params }],
+ queryFn: pageParams => {
+ return get(`/websites/${websiteId}/segments`, { ...pageParams, ...filters, ...params });
+ },
+ enabled: !!websiteId,
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteEventsQuery.ts b/src/components/hooks/queries/useWebsiteEventsQuery.ts
new file mode 100644
index 0000000..fc4dad5
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteEventsQuery.ts
@@ -0,0 +1,39 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+import { usePagedQuery } from '../usePagedQuery';
+
+const EVENT_TYPES = {
+ views: 1,
+ events: 2,
+};
+
+export function useWebsiteEventsQuery(
+ websiteId: string,
+ params?: Record<string, any>,
+ options?: ReactQueryOptions,
+) {
+ const { get } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return usePagedQuery({
+ queryKey: [
+ 'websites:events',
+ { websiteId, startAt, endAt, unit, timezone, ...filters, ...params },
+ ],
+ queryFn: pageParams =>
+ get(`/websites/${websiteId}/events`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ ...pageParams,
+ eventType: EVENT_TYPES[params.view],
+ }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts b/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts
new file mode 100644
index 0000000..6c1d112
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts
@@ -0,0 +1,18 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useWebsiteEventsSeriesQuery(websiteId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery({
+ queryKey: ['websites:events:series', { websiteId, startAt, endAt, unit, timezone, ...filters }],
+ queryFn: () =>
+ get(`/websites/${websiteId}/events/series`, { startAt, endAt, unit, timezone, ...filters }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts b/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts
new file mode 100644
index 0000000..b2e9019
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts
@@ -0,0 +1,51 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export type WebsiteExpandedMetricsData = {
+ name: string;
+ pageviews: number;
+ visitors: number;
+ visits: number;
+ bounces: number;
+ totaltime: number;
+}[];
+
+export function useWebsiteExpandedMetricsQuery(
+ websiteId: string,
+ params: { type: string; limit?: number; search?: string },
+ options?: ReactQueryOptions<WebsiteExpandedMetricsData>,
+) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<WebsiteExpandedMetricsData>({
+ queryKey: [
+ 'websites:metrics:expanded',
+ {
+ websiteId,
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ ...params,
+ },
+ ],
+ queryFn: async () =>
+ get(`/websites/${websiteId}/metrics/expanded`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ ...params,
+ }),
+ enabled: !!websiteId,
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteMetricsQuery.ts b/src/components/hooks/queries/useWebsiteMetricsQuery.ts
new file mode 100644
index 0000000..67c5e4d
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteMetricsQuery.ts
@@ -0,0 +1,47 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export type WebsiteMetricsData = {
+ x: string;
+ y: number;
+}[];
+
+export function useWebsiteMetricsQuery(
+ websiteId: string,
+ params: { type: string; limit?: number; search?: string },
+ options?: ReactQueryOptions<WebsiteMetricsData>,
+) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<WebsiteMetricsData>({
+ queryKey: [
+ 'websites:metrics',
+ {
+ websiteId,
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ ...params,
+ },
+ ],
+ queryFn: async () =>
+ get(`/websites/${websiteId}/metrics`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ ...params,
+ }),
+ enabled: !!websiteId,
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsitePageviewsQuery.ts b/src/components/hooks/queries/useWebsitePageviewsQuery.ts
new file mode 100644
index 0000000..b35c820
--- /dev/null
+++ b/src/components/hooks/queries/useWebsitePageviewsQuery.ts
@@ -0,0 +1,36 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export interface WebsitePageviewsData {
+ pageviews: { x: string; y: number }[];
+ sessions: { x: string; y: number }[];
+}
+
+export function useWebsitePageviewsQuery(
+ { websiteId, compare }: { websiteId: string; compare?: string },
+ options?: ReactQueryOptions<WebsitePageviewsData>,
+) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const queryParams = useFilterParameters();
+
+ return useQuery<WebsitePageviewsData>({
+ queryKey: [
+ 'websites:pageviews',
+ { websiteId, compare, startAt, endAt, unit, timezone, ...queryParams },
+ ],
+ queryFn: () =>
+ get(`/websites/${websiteId}/pageviews`, {
+ compare,
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...queryParams,
+ }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteQuery.ts b/src/components/hooks/queries/useWebsiteQuery.ts
new file mode 100644
index 0000000..b9a5533
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteQuery.ts
@@ -0,0 +1,17 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useWebsiteQuery(websiteId: string, options?: ReactQueryOptions) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`website:${websiteId}`);
+
+ return useQuery({
+ queryKey: ['website', { websiteId, modified }],
+ queryFn: () => get(`/websites/${websiteId}`),
+ enabled: !!websiteId,
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteSegmentQuery.ts b/src/components/hooks/queries/useWebsiteSegmentQuery.ts
new file mode 100644
index 0000000..1923fbd
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteSegmentQuery.ts
@@ -0,0 +1,21 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useWebsiteSegmentQuery(
+ websiteId: string,
+ segmentId: string,
+ options?: ReactQueryOptions,
+) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`segments`);
+
+ return useQuery({
+ queryKey: ['website:segments', { websiteId, segmentId, modified }],
+ queryFn: () => get(`/websites/${websiteId}/segments/${segmentId}`),
+ enabled: !!(websiteId && segmentId),
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteSegmentsQuery.ts b/src/components/hooks/queries/useWebsiteSegmentsQuery.ts
new file mode 100644
index 0000000..8d3af96
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteSegmentsQuery.ts
@@ -0,0 +1,24 @@
+import { keepPreviousData } from '@tanstack/react-query';
+import { useFilterParameters } from '@/components/hooks/useFilterParameters';
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+
+export function useWebsiteSegmentsQuery(
+ websiteId: string,
+ params?: Record<string, string>,
+ options?: ReactQueryOptions,
+) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`segments`);
+ const filters = useFilterParameters();
+
+ return useQuery({
+ queryKey: ['website:segments', { websiteId, modified, ...filters, ...params }],
+ queryFn: pageParams =>
+ get(`/websites/${websiteId}/segments`, { ...pageParams, ...filters, ...params }),
+ enabled: !!websiteId,
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteSessionQuery.ts b/src/components/hooks/queries/useWebsiteSessionQuery.ts
new file mode 100644
index 0000000..21e9491
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteSessionQuery.ts
@@ -0,0 +1,13 @@
+import { useApi } from '../useApi';
+
+export function useWebsiteSessionQuery(websiteId: string, sessionId: string) {
+ const { get, useQuery } = useApi();
+
+ return useQuery({
+ queryKey: ['session', { websiteId, sessionId }],
+ queryFn: () => {
+ return get(`/websites/${websiteId}/sessions/${sessionId}`);
+ },
+ enabled: Boolean(websiteId && sessionId),
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts b/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts
new file mode 100644
index 0000000..bac9fc9
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts
@@ -0,0 +1,17 @@
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+
+export function useWebsiteSessionStatsQuery(websiteId: string, options?: Record<string, string>) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery({
+ queryKey: ['sessions:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }],
+ queryFn: () =>
+ get(`/websites/${websiteId}/sessions/stats`, { startAt, endAt, unit, timezone, ...filters }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteSessionsQuery.ts b/src/components/hooks/queries/useWebsiteSessionsQuery.ts
new file mode 100644
index 0000000..31906be
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteSessionsQuery.ts
@@ -0,0 +1,34 @@
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useFilterParameters } from '../useFilterParameters';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useWebsiteSessionsQuery(
+ websiteId: string,
+ params?: Record<string, string | number>,
+) {
+ const { get } = useApi();
+ const { modified } = useModified(`sessions`);
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return usePagedQuery({
+ queryKey: [
+ 'sessions',
+ { websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters },
+ ],
+ queryFn: pageParams => {
+ return get(`/websites/${websiteId}/sessions`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...filters,
+ ...pageParams,
+ ...params,
+ pageSize: 20,
+ });
+ },
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteStatsQuery.ts b/src/components/hooks/queries/useWebsiteStatsQuery.ts
new file mode 100644
index 0000000..e9a0c48
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteStatsQuery.ts
@@ -0,0 +1,36 @@
+import type { UseQueryOptions } from '@tanstack/react-query';
+import { useDateParameters } from '@/components/hooks/useDateParameters';
+import { useApi } from '../useApi';
+import { useFilterParameters } from '../useFilterParameters';
+
+export interface WebsiteStatsData {
+ pageviews: number;
+ visitors: number;
+ visits: number;
+ bounces: number;
+ totaltime: number;
+ comparison: {
+ pageviews: number;
+ visitors: number;
+ visits: number;
+ bounces: number;
+ totaltime: number;
+ };
+}
+
+export function useWebsiteStatsQuery(
+ websiteId: string,
+ options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>,
+) {
+ const { get, useQuery } = useApi();
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery<WebsiteStatsData>({
+ queryKey: ['websites:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }],
+ queryFn: () =>
+ get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, ...filters }),
+ enabled: !!websiteId,
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWebsiteValuesQuery.ts b/src/components/hooks/queries/useWebsiteValuesQuery.ts
new file mode 100644
index 0000000..1e09736
--- /dev/null
+++ b/src/components/hooks/queries/useWebsiteValuesQuery.ts
@@ -0,0 +1,62 @@
+import { useCountryNames } from '@/components/hooks/useCountryNames';
+import { useRegionNames } from '@/components/hooks/useRegionNames';
+import { useApi } from '../useApi';
+import { useLocale } from '../useLocale';
+
+export function useWebsiteValuesQuery({
+ websiteId,
+ type,
+ startDate,
+ endDate,
+ search,
+}: {
+ websiteId: string;
+ type: string;
+ startDate: Date;
+ endDate: Date;
+ search?: string;
+}) {
+ const { get, useQuery } = useApi();
+ const { locale } = useLocale();
+ const { countryNames } = useCountryNames(locale);
+ const { regionNames } = useRegionNames(locale);
+
+ const names = {
+ country: countryNames,
+ region: regionNames,
+ };
+
+ const getSearch = (type: string, value: string) => {
+ if (value) {
+ const values = names[type];
+
+ if (values) {
+ return (
+ Object.keys(values)
+ .reduce((arr: string[], key: string) => {
+ if (values[key].toLowerCase().includes(value.toLowerCase())) {
+ return arr.concat(key);
+ }
+ return arr;
+ }, [])
+ .slice(0, 5)
+ .join(',') || value
+ );
+ }
+
+ return value;
+ }
+ };
+
+ return useQuery({
+ queryKey: ['websites:values', { websiteId, type, startDate, endDate, search }],
+ queryFn: () =>
+ get(`/websites/${websiteId}/values`, {
+ type,
+ startAt: +startDate,
+ endAt: +endDate,
+ search: getSearch(type, search),
+ }),
+ enabled: !!(websiteId && type && startDate && endDate),
+ });
+}
diff --git a/src/components/hooks/queries/useWebsitesQuery.ts b/src/components/hooks/queries/useWebsitesQuery.ts
new file mode 100644
index 0000000..a7b6618
--- /dev/null
+++ b/src/components/hooks/queries/useWebsitesQuery.ts
@@ -0,0 +1,20 @@
+import type { ReactQueryOptions } from '@/lib/types';
+import { useApi } from '../useApi';
+import { useModified } from '../useModified';
+import { usePagedQuery } from '../usePagedQuery';
+
+export function useWebsitesQuery(params?: Record<string, any>, options?: ReactQueryOptions) {
+ const { get } = useApi();
+ const { modified } = useModified(`websites`);
+
+ return usePagedQuery({
+ queryKey: ['websites:admin', { modified, ...params }],
+ queryFn: pageParams => {
+ return get(`/admin/websites`, {
+ ...pageParams,
+ ...params,
+ });
+ },
+ ...options,
+ });
+}
diff --git a/src/components/hooks/queries/useWeeklyTrafficQuery.ts b/src/components/hooks/queries/useWeeklyTrafficQuery.ts
new file mode 100644
index 0000000..a76ebb3
--- /dev/null
+++ b/src/components/hooks/queries/useWeeklyTrafficQuery.ts
@@ -0,0 +1,28 @@
+import { useFilterParameters } from '@/components/hooks/useFilterParameters';
+import { useApi } from '../useApi';
+import { useDateParameters } from '../useDateParameters';
+import { useModified } from '../useModified';
+
+export function useWeeklyTrafficQuery(websiteId: string, params?: Record<string, string | number>) {
+ const { get, useQuery } = useApi();
+ const { modified } = useModified(`sessions`);
+ const { startAt, endAt, unit, timezone } = useDateParameters();
+ const filters = useFilterParameters();
+
+ return useQuery({
+ queryKey: [
+ 'sessions',
+ { websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters },
+ ],
+ queryFn: () => {
+ return get(`/websites/${websiteId}/sessions/weekly`, {
+ startAt,
+ endAt,
+ unit,
+ timezone,
+ ...params,
+ ...filters,
+ });
+ },
+ });
+}
diff --git a/src/components/hooks/useApi.ts b/src/components/hooks/useApi.ts
new file mode 100644
index 0000000..35cabd5
--- /dev/null
+++ b/src/components/hooks/useApi.ts
@@ -0,0 +1,67 @@
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { useCallback } from 'react';
+import { getClientAuthToken } from '@/lib/client';
+import { SHARE_TOKEN_HEADER } from '@/lib/constants';
+import { type FetchResponse, httpDelete, httpGet, httpPost, httpPut } from '@/lib/fetch';
+import { useApp } from '@/store/app';
+
+const selector = (state: { shareToken: { token?: string } }) => state.shareToken;
+
+async function handleResponse(res: FetchResponse): Promise<any> {
+ if (!res.ok) {
+ const { message, code, status } = res?.data?.error || {};
+
+ return Promise.reject(Object.assign(new Error(message), { code, status }));
+ }
+ return Promise.resolve(res.data);
+}
+
+export function useApi() {
+ const shareToken = useApp(selector);
+
+ const defaultHeaders = {
+ authorization: `Bearer ${getClientAuthToken()}`,
+ [SHARE_TOKEN_HEADER]: shareToken?.token,
+ };
+ const basePath = process.env.basePath;
+
+ const getUrl = (url: string) => {
+ return url.startsWith('http') ? url : `${basePath || ''}/api${url}`;
+ };
+
+ const getHeaders = (headers: any = {}) => {
+ return { ...defaultHeaders, ...headers };
+ };
+
+ return {
+ get: useCallback(
+ async (url: string, params: object = {}, headers: object = {}) => {
+ return httpGet(getUrl(url), params, getHeaders(headers)).then(handleResponse);
+ },
+ [httpGet],
+ ),
+
+ post: useCallback(
+ async (url: string, params: object = {}, headers: object = {}) => {
+ return httpPost(getUrl(url), params, getHeaders(headers)).then(handleResponse);
+ },
+ [httpPost],
+ ),
+
+ put: useCallback(
+ async (url: string, params: object = {}, headers: object = {}) => {
+ return httpPut(getUrl(url), params, getHeaders(headers)).then(handleResponse);
+ },
+ [httpPut],
+ ),
+
+ del: useCallback(
+ async (url: string, params: object = {}, headers: object = {}) => {
+ return httpDelete(getUrl(url), params, getHeaders(headers)).then(handleResponse);
+ },
+ [httpDelete],
+ ),
+ useQuery,
+ useMutation,
+ };
+}
diff --git a/src/components/hooks/useConfig.ts b/src/components/hooks/useConfig.ts
new file mode 100644
index 0000000..c1cdcaf
--- /dev/null
+++ b/src/components/hooks/useConfig.ts
@@ -0,0 +1,33 @@
+import { useEffect } from 'react';
+import { useApi } from '@/components/hooks/useApi';
+import { setConfig, useApp } from '@/store/app';
+
+export type Config = {
+ cloudMode: boolean;
+ faviconUrl?: string;
+ linksUrl?: string;
+ pixelsUrl?: string;
+ privateMode: boolean;
+ telemetryDisabled: boolean;
+ trackerScriptName?: string;
+ updatesDisabled: boolean;
+};
+
+export function useConfig(): Config {
+ const { config } = useApp();
+ const { get } = useApi();
+
+ async function loadConfig() {
+ const data = await get(`/config`);
+
+ setConfig(data);
+ }
+
+ useEffect(() => {
+ if (!config) {
+ loadConfig();
+ }
+ }, []);
+
+ return config;
+}
diff --git a/src/components/hooks/useCountryNames.ts b/src/components/hooks/useCountryNames.ts
new file mode 100644
index 0000000..1ec9fc1
--- /dev/null
+++ b/src/components/hooks/useCountryNames.ts
@@ -0,0 +1,32 @@
+import { useEffect, useState } from 'react';
+import { httpGet } from '@/lib/fetch';
+import enUS from '../../../public/intl/country/en-US.json';
+
+const countryNames = {
+ 'en-US': enUS,
+};
+
+export function useCountryNames(locale: string) {
+ const [list, setList] = useState(countryNames[locale] || enUS);
+
+ async function loadData(locale: string) {
+ const { data } = await httpGet(`${process.env.basePath || ''}/intl/country/${locale}.json`);
+
+ if (data) {
+ countryNames[locale] = data;
+ setList(countryNames[locale]);
+ } else {
+ setList(enUS);
+ }
+ }
+
+ useEffect(() => {
+ if (!countryNames[locale]) {
+ loadData(locale);
+ } else {
+ setList(countryNames[locale]);
+ }
+ }, [locale]);
+
+ return { countryNames: list };
+}
diff --git a/src/components/hooks/useDateParameters.ts b/src/components/hooks/useDateParameters.ts
new file mode 100644
index 0000000..d84b423
--- /dev/null
+++ b/src/components/hooks/useDateParameters.ts
@@ -0,0 +1,18 @@
+import { useDateRange } from './useDateRange';
+import { useTimezone } from './useTimezone';
+
+export function useDateParameters() {
+ const {
+ dateRange: { startDate, endDate, unit },
+ } = useDateRange();
+ const { timezone, localToUtc, canonicalizeTimezone } = useTimezone();
+
+ return {
+ startAt: +localToUtc(startDate),
+ endAt: +localToUtc(endDate),
+ startDate: localToUtc(startDate).toISOString(),
+ endDate: localToUtc(endDate).toISOString(),
+ unit,
+ timezone: canonicalizeTimezone(timezone),
+ };
+}
diff --git a/src/components/hooks/useDateRange.ts b/src/components/hooks/useDateRange.ts
new file mode 100644
index 0000000..755f36e
--- /dev/null
+++ b/src/components/hooks/useDateRange.ts
@@ -0,0 +1,37 @@
+import { useMemo } from 'react';
+import { useLocale } from '@/components/hooks/useLocale';
+import { useNavigation } from '@/components/hooks/useNavigation';
+import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
+import { getCompareDate, getOffsetDateRange, parseDateRange } from '@/lib/date';
+import { getItem } from '@/lib/storage';
+
+export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) {
+ const {
+ query: { date = '', offset = 0, compare = 'prev' },
+ } = useNavigation();
+ const { locale } = useLocale();
+
+ const dateRange = useMemo(() => {
+ const dateRangeObject = parseDateRange(
+ date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE,
+ locale,
+ options.timezone,
+ );
+
+ return !options.ignoreOffset && offset
+ ? getOffsetDateRange(dateRangeObject, +offset)
+ : dateRangeObject;
+ }, [date, offset, options]);
+
+ const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate);
+
+ return {
+ date,
+ offset,
+ compare,
+ isAllTime: date.endsWith(`:all`),
+ isCustomRange: date.startsWith('range:'),
+ dateRange,
+ dateCompare,
+ };
+}
diff --git a/src/components/hooks/useDocumentClick.ts b/src/components/hooks/useDocumentClick.ts
new file mode 100644
index 0000000..611f628
--- /dev/null
+++ b/src/components/hooks/useDocumentClick.ts
@@ -0,0 +1,13 @@
+import { useEffect } from 'react';
+
+export function useDocumentClick(handler: (event: MouseEvent) => any) {
+ useEffect(() => {
+ document.addEventListener('click', handler);
+
+ return () => {
+ document.removeEventListener('click', handler);
+ };
+ }, [handler]);
+
+ return null;
+}
diff --git a/src/components/hooks/useEscapeKey.ts b/src/components/hooks/useEscapeKey.ts
new file mode 100644
index 0000000..cc1d308
--- /dev/null
+++ b/src/components/hooks/useEscapeKey.ts
@@ -0,0 +1,19 @@
+import { type KeyboardEvent, useCallback, useEffect } from 'react';
+
+export function useEscapeKey(handler: (event: KeyboardEvent) => void) {
+ const escFunction = useCallback((event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ handler(event);
+ }
+ }, []);
+
+ useEffect(() => {
+ document.addEventListener('keydown', escFunction as any, false);
+
+ return () => {
+ document.removeEventListener('keydown', escFunction as any, false);
+ };
+ }, [escFunction]);
+
+ return null;
+}
diff --git a/src/components/hooks/useFields.ts b/src/components/hooks/useFields.ts
new file mode 100644
index 0000000..22a1dcf
--- /dev/null
+++ b/src/components/hooks/useFields.ts
@@ -0,0 +1,23 @@
+import { useMessages } from './useMessages';
+
+export function useFields() {
+ const { formatMessage, labels } = useMessages();
+
+ const fields = [
+ { name: 'path', type: 'string', label: formatMessage(labels.path) },
+ { name: 'query', type: 'string', label: formatMessage(labels.query) },
+ { name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
+ { name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
+ { name: 'browser', type: 'string', label: formatMessage(labels.browser) },
+ { name: 'os', type: 'string', label: formatMessage(labels.os) },
+ { name: 'device', type: 'string', label: formatMessage(labels.device) },
+ { name: 'country', type: 'string', label: formatMessage(labels.country) },
+ { name: 'region', type: 'string', label: formatMessage(labels.region) },
+ { name: 'city', type: 'string', label: formatMessage(labels.city) },
+ { name: 'hostname', type: 'string', label: formatMessage(labels.hostname) },
+ { name: 'tag', type: 'string', label: formatMessage(labels.tag) },
+ { name: 'event', type: 'string', label: formatMessage(labels.event) },
+ ];
+
+ return { fields };
+}
diff --git a/src/components/hooks/useFilterParameters.ts b/src/components/hooks/useFilterParameters.ts
new file mode 100644
index 0000000..5403212
--- /dev/null
+++ b/src/components/hooks/useFilterParameters.ts
@@ -0,0 +1,70 @@
+import { useMemo } from 'react';
+import { useNavigation } from './useNavigation';
+
+export function useFilterParameters() {
+ const {
+ query: {
+ path,
+ referrer,
+ title,
+ query,
+ host,
+ os,
+ browser,
+ device,
+ country,
+ region,
+ city,
+ event,
+ tag,
+ hostname,
+ page,
+ pageSize,
+ search,
+ segment,
+ cohort,
+ },
+ } = useNavigation();
+
+ return useMemo(() => {
+ return {
+ path,
+ referrer,
+ title,
+ query,
+ host,
+ os,
+ browser,
+ device,
+ country,
+ region,
+ city,
+ event,
+ tag,
+ hostname,
+ search,
+ segment,
+ cohort,
+ };
+ }, [
+ path,
+ referrer,
+ title,
+ query,
+ host,
+ os,
+ browser,
+ device,
+ country,
+ region,
+ city,
+ event,
+ tag,
+ hostname,
+ page,
+ pageSize,
+ search,
+ segment,
+ cohort,
+ ]);
+}
diff --git a/src/components/hooks/useFilters.ts b/src/components/hooks/useFilters.ts
new file mode 100644
index 0000000..850e2af
--- /dev/null
+++ b/src/components/hooks/useFilters.ts
@@ -0,0 +1,99 @@
+import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants';
+import { safeDecodeURIComponent } from '@/lib/url';
+import { useFields } from './useFields';
+import { useMessages } from './useMessages';
+import { useNavigation } from './useNavigation';
+
+export function useFilters() {
+ const { formatMessage, labels } = useMessages();
+ const { query } = useNavigation();
+ const { fields } = useFields();
+
+ const operators = [
+ { name: 'eq', type: 'string', label: formatMessage(labels.is) },
+ { name: 'neq', type: 'string', label: formatMessage(labels.isNot) },
+ { name: 'c', type: 'string', label: formatMessage(labels.contains) },
+ { name: 'dnc', type: 'string', label: formatMessage(labels.doesNotContain) },
+ { name: 'i', type: 'array', label: formatMessage(labels.includes) },
+ { name: 'dni', type: 'array', label: formatMessage(labels.doesNotInclude) },
+ { name: 't', type: 'boolean', label: formatMessage(labels.isTrue) },
+ { name: 'f', type: 'boolean', label: formatMessage(labels.isFalse) },
+ { name: 'eq', type: 'number', label: formatMessage(labels.is) },
+ { name: 'neq', type: 'number', label: formatMessage(labels.isNot) },
+ { name: 'gt', type: 'number', label: formatMessage(labels.greaterThan) },
+ { name: 'lt', type: 'number', label: formatMessage(labels.lessThan) },
+ { name: 'gte', type: 'number', label: formatMessage(labels.greaterThanEquals) },
+ { name: 'lte', type: 'number', label: formatMessage(labels.lessThanEquals) },
+ { name: 'bf', type: 'date', label: formatMessage(labels.before) },
+ { name: 'af', type: 'date', label: formatMessage(labels.after) },
+ { name: 'eq', type: 'uuid', label: formatMessage(labels.is) },
+ ];
+
+ const operatorLabels = {
+ [OPERATORS.equals]: formatMessage(labels.is),
+ [OPERATORS.notEquals]: formatMessage(labels.isNot),
+ [OPERATORS.set]: formatMessage(labels.isSet),
+ [OPERATORS.notSet]: formatMessage(labels.isNotSet),
+ [OPERATORS.contains]: formatMessage(labels.contains),
+ [OPERATORS.doesNotContain]: formatMessage(labels.doesNotContain),
+ [OPERATORS.true]: formatMessage(labels.true),
+ [OPERATORS.false]: formatMessage(labels.false),
+ [OPERATORS.greaterThan]: formatMessage(labels.greaterThan),
+ [OPERATORS.lessThan]: formatMessage(labels.lessThan),
+ [OPERATORS.greaterThanEquals]: formatMessage(labels.greaterThanEquals),
+ [OPERATORS.lessThanEquals]: formatMessage(labels.lessThanEquals),
+ [OPERATORS.before]: formatMessage(labels.before),
+ [OPERATORS.after]: formatMessage(labels.after),
+ };
+
+ const typeFilters = {
+ string: [OPERATORS.equals, OPERATORS.notEquals, OPERATORS.contains, OPERATORS.doesNotContain],
+ array: [OPERATORS.contains, OPERATORS.doesNotContain],
+ boolean: [OPERATORS.true, OPERATORS.false],
+ number: [
+ OPERATORS.equals,
+ OPERATORS.notEquals,
+ OPERATORS.greaterThan,
+ OPERATORS.lessThan,
+ OPERATORS.greaterThanEquals,
+ OPERATORS.lessThanEquals,
+ ],
+ date: [OPERATORS.before, OPERATORS.after],
+ uuid: [OPERATORS.equals],
+ };
+
+ const filters = Object.keys(query).reduce((arr, key) => {
+ if (FILTER_COLUMNS[key]) {
+ let operator = 'eq';
+ let value = safeDecodeURIComponent(query[key]);
+ const label = fields.find(({ name }) => name === key)?.label;
+
+ const match = value.match(/^([a-z]+)\.(.*)/);
+
+ if (match) {
+ operator = match[1];
+ value = match[2];
+ }
+
+ return arr.concat({
+ name: key,
+ operator,
+ value,
+ label,
+ });
+ }
+ return arr;
+ }, []);
+
+ const getFilters = (type: string) => {
+ return (
+ typeFilters[type]?.map((key: string | number) => ({
+ type,
+ value: key,
+ label: operatorLabels[key],
+ })) ?? []
+ );
+ };
+
+ return { fields, operators, filters, operatorLabels, typeFilters, getFilters };
+}
diff --git a/src/components/hooks/useForceUpdate.ts b/src/components/hooks/useForceUpdate.ts
new file mode 100644
index 0000000..550cc5c
--- /dev/null
+++ b/src/components/hooks/useForceUpdate.ts
@@ -0,0 +1,9 @@
+import { useCallback, useState } from 'react';
+
+export function useForceUpdate() {
+ const [, update] = useState(Object.create(null));
+
+ return useCallback(() => {
+ update(Object.create(null));
+ }, [update]);
+}
diff --git a/src/components/hooks/useFormat.ts b/src/components/hooks/useFormat.ts
new file mode 100644
index 0000000..896fa07
--- /dev/null
+++ b/src/components/hooks/useFormat.ts
@@ -0,0 +1,74 @@
+import { BROWSERS, OS_NAMES } from '@/lib/constants';
+import regions from '../../../public/iso-3166-2.json';
+import { useCountryNames } from './useCountryNames';
+import { useLanguageNames } from './useLanguageNames';
+import { useLocale } from './useLocale';
+import { useMessages } from './useMessages';
+
+export function useFormat() {
+ const { formatMessage, labels } = useMessages();
+ const { locale } = useLocale();
+ const { countryNames } = useCountryNames(locale);
+ const { languageNames } = useLanguageNames(locale);
+
+ const formatOS = (value: string): string => {
+ return OS_NAMES[value] || value;
+ };
+
+ const formatBrowser = (value: string): string => {
+ return BROWSERS[value] || value;
+ };
+
+ const formatDevice = (value: string): string => {
+ return formatMessage(labels[value] || labels.unknown);
+ };
+
+ const formatCountry = (value: string): string => {
+ return countryNames[value] || value;
+ };
+
+ const formatRegion = (value?: string): string => {
+ const [country] = value?.split('-') || [];
+ return regions[value] ? `${regions[value]}, ${countryNames[country]}` : value;
+ };
+
+ const formatCity = (value: string, country?: string): string => {
+ return countryNames[country] ? `${value}, ${countryNames[country]}` : value;
+ };
+
+ const formatLanguage = (value: string): string => {
+ return languageNames[value?.split('-')[0]] || value;
+ };
+
+ const formatValue = (value: string, type: string, data?: Record<string, any>): string => {
+ switch (type) {
+ case 'os':
+ return formatOS(value);
+ case 'browser':
+ return formatBrowser(value);
+ case 'device':
+ return formatDevice(value);
+ case 'country':
+ return formatCountry(value);
+ case 'region':
+ return formatRegion(value);
+ case 'city':
+ return formatCity(value, data?.country);
+ case 'language':
+ return formatLanguage(value);
+ default:
+ return typeof value === 'string' ? value : undefined;
+ }
+ };
+
+ return {
+ formatOS,
+ formatBrowser,
+ formatDevice,
+ formatCountry,
+ formatRegion,
+ formatCity,
+ formatLanguage,
+ formatValue,
+ };
+}
diff --git a/src/components/hooks/useGlobalState.ts b/src/components/hooks/useGlobalState.ts
new file mode 100644
index 0000000..6f21226
--- /dev/null
+++ b/src/components/hooks/useGlobalState.ts
@@ -0,0 +1,13 @@
+import { create } from 'zustand';
+
+const store = create(() => ({}));
+
+const useGlobalState = (key: string, value?: any) => {
+ if (value !== undefined && store.getState()[key] === undefined) {
+ store.setState({ [key]: value });
+ }
+
+ return [store(state => state[key]), (value: any) => store.setState({ [key]: value })];
+};
+
+export { useGlobalState };
diff --git a/src/components/hooks/useLanguageNames.ts b/src/components/hooks/useLanguageNames.ts
new file mode 100644
index 0000000..0cc03d7
--- /dev/null
+++ b/src/components/hooks/useLanguageNames.ts
@@ -0,0 +1,32 @@
+import { useEffect, useState } from 'react';
+import { httpGet } from '@/lib/fetch';
+import enUS from '../../../public/intl/language/en-US.json';
+
+const languageNames = {
+ 'en-US': enUS,
+};
+
+export function useLanguageNames(locale) {
+ const [list, setList] = useState(languageNames[locale] || enUS);
+
+ async function loadData(locale) {
+ const { data } = await httpGet(`${process.env.basePath || ''}/intl/language/${locale}.json`);
+
+ if (data) {
+ languageNames[locale] = data;
+ setList(languageNames[locale]);
+ } else {
+ setList(enUS);
+ }
+ }
+
+ useEffect(() => {
+ if (!languageNames[locale]) {
+ loadData(locale);
+ } else {
+ setList(languageNames[locale]);
+ }
+ }, [locale]);
+
+ return { languageNames: list };
+}
diff --git a/src/components/hooks/useLocale.ts b/src/components/hooks/useLocale.ts
new file mode 100644
index 0000000..3eb669e
--- /dev/null
+++ b/src/components/hooks/useLocale.ts
@@ -0,0 +1,60 @@
+import { useEffect } from 'react';
+import { LOCALE_CONFIG } from '@/lib/constants';
+import { httpGet } from '@/lib/fetch';
+import { getDateLocale, getTextDirection } from '@/lib/lang';
+import { setItem } from '@/lib/storage';
+import { setLocale, useApp } from '@/store/app';
+import enUS from '../../../public/intl/country/en-US.json';
+import { useForceUpdate } from './useForceUpdate';
+
+const messages = {
+ 'en-US': enUS,
+};
+
+const selector = (state: { locale: string }) => state.locale;
+
+export function useLocale() {
+ const locale = useApp(selector);
+ const forceUpdate = useForceUpdate();
+ const dir = getTextDirection(locale);
+ const dateLocale = getDateLocale(locale);
+
+ async function loadMessages(locale: string) {
+ const { data } = await httpGet(`${process.env.basePath || ''}/intl/messages/${locale}.json`);
+
+ messages[locale] = data;
+ }
+
+ async function saveLocale(value: string) {
+ if (!messages[value]) {
+ await loadMessages(value);
+ }
+
+ setItem(LOCALE_CONFIG, value);
+
+ document.getElementById('__next')?.setAttribute('dir', getTextDirection(value));
+
+ if (locale !== value) {
+ setLocale(value);
+ } else {
+ forceUpdate();
+ }
+ }
+
+ useEffect(() => {
+ if (!messages[locale]) {
+ saveLocale(locale);
+ }
+ }, [locale]);
+
+ useEffect(() => {
+ const url = new URL(window?.location?.href);
+ const locale = url.searchParams.get('locale');
+
+ if (locale) {
+ saveLocale(locale);
+ }
+ }, []);
+
+ return { locale, saveLocale, messages, dir, dateLocale };
+}
diff --git a/src/components/hooks/useMessages.ts b/src/components/hooks/useMessages.ts
new file mode 100644
index 0000000..d5bc242
--- /dev/null
+++ b/src/components/hooks/useMessages.ts
@@ -0,0 +1,48 @@
+import { FormattedMessage, type MessageDescriptor, useIntl } from 'react-intl';
+import { labels, messages } from '@/components/messages';
+import type { ApiError } from '@/lib/types';
+
+type FormatMessage = (
+ descriptor: MessageDescriptor,
+ values?: Record<string, string | number | boolean | null | undefined>,
+ opts?: any,
+) => string | null;
+
+interface UseMessages {
+ formatMessage: FormatMessage;
+ messages: typeof messages;
+ labels: typeof labels;
+ getMessage: (id: string) => string;
+ getErrorMessage: (error: ApiError) => string | undefined;
+ FormattedMessage: typeof FormattedMessage;
+}
+
+export function useMessages(): UseMessages {
+ const intl = useIntl();
+
+ const getMessage = (id: string) => {
+ const message = Object.values(messages).find(value => value.id === `message.${id}`);
+
+ return message ? formatMessage(message) : id;
+ };
+
+ const getErrorMessage = (error: ApiError) => {
+ if (!error) {
+ return undefined;
+ }
+
+ const code = error?.code;
+
+ return code ? getMessage(code) : error?.message || 'Unknown error';
+ };
+
+ const formatMessage = (
+ descriptor: MessageDescriptor,
+ values?: Record<string, string | number | boolean | null | undefined>,
+ opts?: any,
+ ) => {
+ return descriptor ? intl.formatMessage(descriptor, values, opts) : null;
+ };
+
+ return { formatMessage, messages, labels, getMessage, getErrorMessage, FormattedMessage };
+}
diff --git a/src/components/hooks/useMobile.ts b/src/components/hooks/useMobile.ts
new file mode 100644
index 0000000..6b40f3d
--- /dev/null
+++ b/src/components/hooks/useMobile.ts
@@ -0,0 +1,9 @@
+import { useBreakpoint } from '@umami/react-zen';
+
+export function useMobile() {
+ const breakpoint = useBreakpoint();
+ const isMobile = ['xs', 'sm', 'md'].includes(breakpoint);
+ const isPhone = ['xs', 'sm'].includes(breakpoint);
+
+ return { breakpoint, isMobile, isPhone };
+}
diff --git a/src/components/hooks/useModified.ts b/src/components/hooks/useModified.ts
new file mode 100644
index 0000000..ea88888
--- /dev/null
+++ b/src/components/hooks/useModified.ts
@@ -0,0 +1,13 @@
+import { create } from 'zustand';
+
+const store = create(() => ({}));
+
+export function touch(key: string) {
+ store.setState({ [key]: Date.now() });
+}
+
+export function useModified(key?: string) {
+ const modified = store(state => state?.[key]);
+
+ return { modified, touch };
+}
diff --git a/src/components/hooks/useNavigation.ts b/src/components/hooks/useNavigation.ts
new file mode 100644
index 0000000..0a18ac7
--- /dev/null
+++ b/src/components/hooks/useNavigation.ts
@@ -0,0 +1,43 @@
+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
+import { useEffect, useState } from 'react';
+import { buildPath } from '@/lib/url';
+
+export function useNavigation() {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const [, teamId] = pathname.match(/\/teams\/([a-f0-9-]+)/) || [];
+ const [, websiteId] = pathname.match(/\/websites\/([a-f0-9-]+)/) || [];
+ const [queryParams, setQueryParams] = useState(Object.fromEntries(searchParams));
+
+ const updateParams = (params?: Record<string, string | number>) => {
+ return buildPath(pathname, { ...queryParams, ...params });
+ };
+
+ const replaceParams = (params?: Record<string, string | number>) => {
+ return buildPath(pathname, params);
+ };
+
+ const renderUrl = (path: string, params?: Record<string, string | number> | false) => {
+ return buildPath(
+ teamId ? `/teams/${teamId}${path}` : path,
+ params === false ? {} : { ...queryParams, ...params },
+ );
+ };
+
+ useEffect(() => {
+ setQueryParams(Object.fromEntries(searchParams));
+ }, [searchParams.toString()]);
+
+ return {
+ router,
+ pathname,
+ searchParams,
+ query: queryParams,
+ teamId,
+ websiteId,
+ updateParams,
+ replaceParams,
+ renderUrl,
+ };
+}
diff --git a/src/components/hooks/usePageParameters.ts b/src/components/hooks/usePageParameters.ts
new file mode 100644
index 0000000..42cf391
--- /dev/null
+++ b/src/components/hooks/usePageParameters.ts
@@ -0,0 +1,16 @@
+import { useMemo } from 'react';
+import { useNavigation } from './useNavigation';
+
+export function usePageParameters() {
+ const {
+ query: { page, pageSize, search },
+ } = useNavigation();
+
+ return useMemo(() => {
+ return {
+ page,
+ pageSize,
+ search,
+ };
+ }, [page, pageSize, search]);
+}
diff --git a/src/components/hooks/usePagedQuery.ts b/src/components/hooks/usePagedQuery.ts
new file mode 100644
index 0000000..c818de6
--- /dev/null
+++ b/src/components/hooks/usePagedQuery.ts
@@ -0,0 +1,27 @@
+import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
+import type { PageResult } from '@/lib/types';
+import { useApi } from './useApi';
+import { useNavigation } from './useNavigation';
+
+export function usePagedQuery<TData = any, TError = Error>({
+ queryKey,
+ queryFn,
+ ...options
+}: Omit<
+ UseQueryOptions<PageResult<TData>, TError, PageResult<TData>, readonly unknown[]>,
+ 'queryFn' | 'queryKey'
+> & {
+ queryKey: readonly unknown[];
+ queryFn: (params?: object) => Promise<PageResult<TData>> | PageResult<TData>;
+}): UseQueryResult<PageResult<TData>, TError> {
+ const {
+ query: { page, search },
+ } = useNavigation();
+ const { useQuery } = useApi();
+
+ return useQuery<PageResult<TData>, TError>({
+ queryKey: [...queryKey, page, search] as const,
+ queryFn: () => queryFn({ page, search }),
+ ...options,
+ });
+}
diff --git a/src/components/hooks/useRegionNames.ts b/src/components/hooks/useRegionNames.ts
new file mode 100644
index 0000000..57dcc41
--- /dev/null
+++ b/src/components/hooks/useRegionNames.ts
@@ -0,0 +1,22 @@
+import regions from '../../../public/iso-3166-2.json';
+import { useCountryNames } from './useCountryNames';
+
+export function useRegionNames(locale: string) {
+ const { countryNames } = useCountryNames(locale);
+
+ const getRegionName = (regionCode: string, countryCode?: string) => {
+ if (!countryCode) {
+ return regions[regionCode];
+ }
+
+ if (!regionCode) {
+ return null;
+ }
+
+ const region = regionCode?.includes('-') ? regionCode : `${countryCode}-${regionCode}`;
+
+ return regions[region] ? `${regions[region]}, ${countryNames[countryCode]}` : region;
+ };
+
+ return { regionNames: regions, getRegionName };
+}
diff --git a/src/components/hooks/useSlug.ts b/src/components/hooks/useSlug.ts
new file mode 100644
index 0000000..f795dfe
--- /dev/null
+++ b/src/components/hooks/useSlug.ts
@@ -0,0 +1,14 @@
+import { useConfig } from '@/components/hooks/useConfig';
+import { LINKS_URL, PIXELS_URL } from '@/lib/constants';
+
+export function useSlug(type: 'link' | 'pixel') {
+ const { linksUrl, pixelsUrl } = useConfig();
+
+ const hostUrl = type === 'link' ? linksUrl || LINKS_URL : pixelsUrl || PIXELS_URL;
+
+ const getSlugUrl = (slug: string) => {
+ return `${hostUrl}/${slug}`;
+ };
+
+ return { getSlugUrl, hostUrl };
+}
diff --git a/src/components/hooks/useSticky.ts b/src/components/hooks/useSticky.ts
new file mode 100644
index 0000000..ef9fb36
--- /dev/null
+++ b/src/components/hooks/useSticky.ts
@@ -0,0 +1,25 @@
+import { useEffect, useRef, useState } from 'react';
+
+export function useSticky({ enabled = true, threshold = 1 }) {
+ const [isSticky, setIsSticky] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ let observer: IntersectionObserver | undefined;
+ // eslint-disable-next-line no-undef
+ const handler: IntersectionObserverCallback = ([entry]) =>
+ setIsSticky(entry.intersectionRatio < threshold);
+
+ if (enabled && ref.current) {
+ observer = new IntersectionObserver(handler, { threshold: [threshold] });
+ observer.observe(ref.current);
+ }
+ return () => {
+ if (observer) {
+ observer.disconnect();
+ }
+ };
+ }, [ref, enabled, threshold]);
+
+ return { ref, isSticky };
+}
diff --git a/src/components/hooks/useTimezone.ts b/src/components/hooks/useTimezone.ts
new file mode 100644
index 0000000..ef25539
--- /dev/null
+++ b/src/components/hooks/useTimezone.ts
@@ -0,0 +1,95 @@
+import { formatInTimeZone, utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz';
+import { TIMEZONE_CONFIG, TIMEZONE_LEGACY } from '@/lib/constants';
+import { getTimezone } from '@/lib/date';
+import { setItem } from '@/lib/storage';
+import { setTimezone, useApp } from '@/store/app';
+import { useLocale } from './useLocale';
+
+const selector = (state: { timezone: string }) => state.timezone;
+
+export function useTimezone() {
+ const timezone = useApp(selector);
+ const localTimeZone = getTimezone();
+ const { dateLocale } = useLocale();
+
+ const saveTimezone = (value: string) => {
+ setItem(TIMEZONE_CONFIG, value);
+ setTimezone(value);
+ };
+
+ const formatTimezoneDate = (date: string, pattern: string) => {
+ return formatInTimeZone(
+ /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{3})?Z$/.test(date)
+ ? date
+ : `${date.split(' ').join('T')}Z`,
+ timezone,
+ pattern,
+ { locale: dateLocale },
+ );
+ };
+
+ const formatSeriesTimezone = (data: any, column: string, timezone: string) => {
+ return data.map(item => {
+ const date = new Date(item[column]);
+
+ const format = new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
+ hour12: false,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+
+ const parts = format.formatToParts(date);
+ const get = type => parts.find(p => p.type === type)?.value;
+
+ const year = get('year');
+ const month = get('month');
+ const day = get('day');
+ const hour = get('hour');
+ const minute = get('minute');
+ const second = get('second');
+
+ return {
+ ...item,
+ [column]: `${year}-${month}-${day} ${hour}:${minute}:${second}`,
+ };
+ });
+ };
+
+ const toUtc = (date: Date | string | number) => {
+ return zonedTimeToUtc(date, timezone);
+ };
+
+ const fromUtc = (date: Date | string | number) => {
+ return utcToZonedTime(date, timezone);
+ };
+
+ const localToUtc = (date: Date | string | number) => {
+ return zonedTimeToUtc(date, localTimeZone);
+ };
+
+ const localFromUtc = (date: Date | string | number) => {
+ return utcToZonedTime(date, localTimeZone);
+ };
+
+ const canonicalizeTimezone = (timezone: string): string => {
+ return TIMEZONE_LEGACY[timezone] ?? timezone;
+ };
+
+ return {
+ timezone,
+ localTimeZone,
+ toUtc,
+ fromUtc,
+ localToUtc,
+ localFromUtc,
+ saveTimezone,
+ formatTimezoneDate,
+ formatSeriesTimezone,
+ canonicalizeTimezone,
+ };
+}
diff --git a/src/components/icons.ts b/src/components/icons.ts
new file mode 100644
index 0000000..fe433d5
--- /dev/null
+++ b/src/components/icons.ts
@@ -0,0 +1 @@
+export * from 'lucide-react';
diff --git a/src/components/input/ActionSelect.tsx b/src/components/input/ActionSelect.tsx
new file mode 100644
index 0000000..616ee34
--- /dev/null
+++ b/src/components/input/ActionSelect.tsx
@@ -0,0 +1,18 @@
+import { ListItem, Select } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+
+export interface ActionSelectProps {
+ value?: string;
+ onChange?: (value: string) => void;
+}
+
+export function ActionSelect({ value = 'path', onChange }: ActionSelectProps) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <Select value={value} onChange={onChange}>
+ <ListItem id="path">{formatMessage(labels.viewedPage)}</ListItem>
+ <ListItem id="event">{formatMessage(labels.triggeredEvent)}</ListItem>
+ </Select>
+ );
+}
diff --git a/src/components/input/CurrencySelect.tsx b/src/components/input/CurrencySelect.tsx
new file mode 100644
index 0000000..2b6045b
--- /dev/null
+++ b/src/components/input/CurrencySelect.tsx
@@ -0,0 +1,34 @@
+import { ListItem, Select } from '@umami/react-zen';
+import { useState } from 'react';
+import { useMessages } from '@/components/hooks';
+import { CURRENCIES } from '@/lib/constants';
+
+export function CurrencySelect({ value, onChange }) {
+ const { formatMessage, labels } = useMessages();
+ const [search, setSearch] = useState('');
+
+ return (
+ <Select
+ items={CURRENCIES}
+ label={formatMessage(labels.currency)}
+ value={value}
+ defaultValue={value}
+ onChange={onChange}
+ listProps={{ style: { maxHeight: 300 } }}
+ onSearch={setSearch}
+ allowSearch
+ >
+ {CURRENCIES.map(({ id, name }) => {
+ if (search && !`${id}${name}`.toLowerCase().includes(search)) {
+ return null;
+ }
+
+ return (
+ <ListItem key={id} id={id}>
+ {id} &mdash; {name}
+ </ListItem>
+ );
+ }).filter(n => n)}
+ </Select>
+ );
+}
diff --git a/src/components/input/DateFilter.tsx b/src/components/input/DateFilter.tsx
new file mode 100644
index 0000000..2e17529
--- /dev/null
+++ b/src/components/input/DateFilter.tsx
@@ -0,0 +1,141 @@
+import { Dialog, ListItem, ListSeparator, Modal, Select, type SelectProps } from '@umami/react-zen';
+import { endOfYear } from 'date-fns';
+import { Fragment, type Key, useState } from 'react';
+import { DateDisplay } from '@/components/common/DateDisplay';
+import { useMessages, useMobile } from '@/components/hooks';
+import { DatePickerForm } from '@/components/metrics/DatePickerForm';
+import { parseDateRange } from '@/lib/date';
+
+export interface DateFilterProps extends SelectProps {
+ value?: string;
+ onChange?: (value: string) => void;
+ showAllTime?: boolean;
+ renderDate?: boolean;
+ placement?: any;
+}
+
+export function DateFilter({
+ value,
+ onChange,
+ showAllTime,
+ renderDate,
+ placement = 'bottom',
+ ...props
+}: DateFilterProps) {
+ const { formatMessage, labels } = useMessages();
+ const [showPicker, setShowPicker] = useState(false);
+ const { startDate, endDate } = parseDateRange(value) || {};
+ const { isMobile } = useMobile();
+
+ const options = [
+ { label: formatMessage(labels.today), value: '0day' },
+ {
+ label: formatMessage(labels.lastHours, { x: '24' }),
+ value: '24hour',
+ },
+ {
+ label: formatMessage(labels.thisWeek),
+ value: '0week',
+ divider: true,
+ },
+ {
+ label: formatMessage(labels.lastDays, { x: '7' }),
+ value: '7day',
+ },
+ {
+ label: formatMessage(labels.thisMonth),
+ value: '0month',
+ divider: true,
+ },
+ {
+ label: formatMessage(labels.lastDays, { x: '30' }),
+ value: '30day',
+ },
+ {
+ label: formatMessage(labels.lastDays, { x: '90' }),
+ value: '90day',
+ },
+ { label: formatMessage(labels.thisYear), value: '0year' },
+ {
+ label: formatMessage(labels.lastMonths, { x: '6' }),
+ value: '6month',
+ divider: true,
+ },
+ {
+ label: formatMessage(labels.lastMonths, { x: '12' }),
+ value: '12month',
+ },
+ showAllTime && {
+ label: formatMessage(labels.allTime),
+ value: 'all',
+ divider: true,
+ },
+ {
+ label: formatMessage(labels.customRange),
+ value: 'custom',
+ divider: true,
+ },
+ ]
+ .filter(n => n)
+ .map((a, id) => ({ ...a, id }));
+
+ const handleChange = (value: Key) => {
+ if (value === 'custom') {
+ setShowPicker(true);
+ return;
+ }
+ onChange(value.toString());
+ };
+
+ const handlePickerChange = (value: string) => {
+ setShowPicker(false);
+ onChange(value.toString());
+ };
+
+ const renderValue = ({ defaultChildren }) => {
+ return value?.startsWith('range') || renderDate ? (
+ <DateDisplay startDate={startDate} endDate={endDate} />
+ ) : (
+ defaultChildren
+ );
+ };
+
+ const selectedValue = value.endsWith(':all') ? 'all' : value;
+
+ return (
+ <>
+ <Select
+ {...props}
+ value={selectedValue}
+ placeholder={formatMessage(labels.selectDate)}
+ onChange={handleChange}
+ renderValue={renderValue}
+ popoverProps={{ placement }}
+ isFullscreen={isMobile}
+ >
+ {options.map(({ label, value, divider }: any) => {
+ return (
+ <Fragment key={label}>
+ {divider && <ListSeparator />}
+ <ListItem id={value}>{label}</ListItem>
+ </Fragment>
+ );
+ })}
+ </Select>
+ {showPicker && (
+ <Modal isOpen={true}>
+ <Dialog>
+ <DatePickerForm
+ startDate={startDate}
+ endDate={endDate}
+ minDate={new Date(2000, 0, 1)}
+ maxDate={endOfYear(new Date())}
+ onChange={handlePickerChange}
+ onClose={() => setShowPicker(false)}
+ />
+ </Dialog>
+ </Modal>
+ )}
+ </>
+ );
+}
diff --git a/src/components/input/DialogButton.tsx b/src/components/input/DialogButton.tsx
new file mode 100644
index 0000000..7527226
--- /dev/null
+++ b/src/components/input/DialogButton.tsx
@@ -0,0 +1,64 @@
+import {
+ Button,
+ type ButtonProps,
+ Dialog,
+ type DialogProps,
+ DialogTrigger,
+ IconLabel,
+ Modal,
+} from '@umami/react-zen';
+import type { CSSProperties, ReactNode } from 'react';
+import { useMobile } from '@/components/hooks';
+
+export interface DialogButtonProps extends Omit<ButtonProps, 'children'> {
+ icon?: ReactNode;
+ label?: ReactNode;
+ title?: ReactNode;
+ width?: string;
+ height?: string;
+ minWidth?: string;
+ minHeight?: string;
+ children?: DialogProps['children'];
+}
+
+export function DialogButton({
+ icon,
+ label,
+ title,
+ width,
+ height,
+ minWidth,
+ minHeight,
+ children,
+ ...props
+}: DialogButtonProps) {
+ const { isMobile } = useMobile();
+ const style: CSSProperties = {
+ width,
+ height,
+ minWidth,
+ minHeight,
+ maxHeight: 'calc(100dvh - 40px)',
+ padding: '32px',
+ };
+
+ if (isMobile) {
+ style.width = '100%';
+ style.height = '100%';
+ style.maxHeight = '100%';
+ style.overflowY = 'auto';
+ }
+
+ return (
+ <DialogTrigger>
+ <Button {...props}>
+ <IconLabel icon={icon} label={label} />
+ </Button>
+ <Modal placement={isMobile ? 'fullscreen' : 'center'}>
+ <Dialog variant={isMobile ? 'sheet' : undefined} title={title || label} style={style}>
+ {children}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/components/input/DownloadButton.tsx b/src/components/input/DownloadButton.tsx
new file mode 100644
index 0000000..5df3305
--- /dev/null
+++ b/src/components/input/DownloadButton.tsx
@@ -0,0 +1,42 @@
+import { Button, Icon, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import Papa from 'papaparse';
+import { useMessages } from '@/components/hooks';
+import { Download } from '@/components/icons';
+
+export function DownloadButton({
+ filename = 'data',
+ data,
+}: {
+ filename?: string;
+ data?: any;
+ onClick?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+
+ const handleClick = async () => {
+ downloadCsv(`${filename}.csv`, Papa.unparse(data));
+ };
+
+ return (
+ <TooltipTrigger delay={0}>
+ <Button variant="quiet" onClick={handleClick} isDisabled={!data || data.length === 0}>
+ <Icon>
+ <Download />
+ </Icon>
+ </Button>
+ <Tooltip>{formatMessage(labels.download)}</Tooltip>
+ </TooltipTrigger>
+ );
+}
+
+function downloadCsv(filename: string, data: any) {
+ const blob = new Blob([data], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+
+ URL.revokeObjectURL(url);
+}
diff --git a/src/components/input/ExportButton.tsx b/src/components/input/ExportButton.tsx
new file mode 100644
index 0000000..7b65a57
--- /dev/null
+++ b/src/components/input/ExportButton.tsx
@@ -0,0 +1,64 @@
+import { Icon, LoadingButton, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import { useSearchParams } from 'next/navigation';
+import { useState } from 'react';
+import { useApi, useMessages } from '@/components/hooks';
+import { useDateParameters } from '@/components/hooks/useDateParameters';
+import { useFilterParameters } from '@/components/hooks/useFilterParameters';
+import { Download } from '@/components/icons';
+
+export function ExportButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const [isLoading, setIsLoading] = useState(false);
+ const date = useDateParameters();
+ const filters = useFilterParameters();
+ const searchParams = useSearchParams();
+ const { get } = useApi();
+
+ const handleClick = async () => {
+ setIsLoading(true);
+
+ const { zip } = await get(`/websites/${websiteId}/export`, {
+ ...date,
+ ...filters,
+ ...searchParams,
+ format: 'json',
+ });
+
+ await loadZip(zip);
+
+ setIsLoading(false);
+ };
+
+ return (
+ <TooltipTrigger delay={0}>
+ <LoadingButton
+ variant="quiet"
+ showText={!isLoading}
+ isLoading={isLoading}
+ onClick={handleClick}
+ >
+ <Icon>
+ <Download />
+ </Icon>
+ </LoadingButton>
+ <Tooltip>{formatMessage(labels.download)}</Tooltip>
+ </TooltipTrigger>
+ );
+}
+
+async function loadZip(zip: string) {
+ const binary = atob(zip);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+
+ const blob = new Blob([bytes], { type: 'application/zip' });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'download.zip';
+ a.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/src/components/input/FieldFilters.tsx b/src/components/input/FieldFilters.tsx
new file mode 100644
index 0000000..2174068
--- /dev/null
+++ b/src/components/input/FieldFilters.tsx
@@ -0,0 +1,117 @@
+import {
+ Button,
+ Column,
+ Grid,
+ Icon,
+ List,
+ ListItem,
+ Menu,
+ MenuItem,
+ MenuTrigger,
+ Popover,
+ Row,
+} from '@umami/react-zen';
+import { endOfDay, subMonths } from 'date-fns';
+import type { Key } from 'react';
+import { Empty } from '@/components/common/Empty';
+import { FilterRecord } from '@/components/common/FilterRecord';
+import { useFields, useMessages, useMobile } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+
+export interface FieldFiltersProps {
+ websiteId: string;
+ value?: { name: string; operator: string; value: string }[];
+ exclude?: string[];
+ onChange?: (data: any) => void;
+}
+
+export function FieldFilters({ websiteId, value, exclude = [], onChange }: FieldFiltersProps) {
+ const { formatMessage, messages } = useMessages();
+ const { fields } = useFields();
+ const startDate = subMonths(endOfDay(new Date()), 6);
+ const endDate = endOfDay(new Date());
+ const { isMobile } = useMobile();
+
+ const updateFilter = (name: string, props: Record<string, any>) => {
+ onChange(value.map(filter => (filter.name === name ? { ...filter, ...props } : filter)));
+ };
+
+ const handleAdd = (name: Key) => {
+ onChange(value.concat({ name: name.toString(), operator: 'eq', value: '' }));
+ };
+
+ const handleChange = (name: string, value: Key) => {
+ updateFilter(name, { value });
+ };
+
+ const handleSelect = (name: string, operator: Key) => {
+ updateFilter(name, { operator });
+ };
+
+ const handleRemove = (name: string) => {
+ onChange(value.filter(filter => filter.name !== name));
+ };
+
+ return (
+ <Grid columns={{ xs: '1fr', md: '180px 1fr' }} overflow="hidden" gapY="6">
+ <Row display={{ xs: 'flex', md: 'none' }}>
+ <MenuTrigger>
+ <Button>
+ <Icon>
+ <Plus />
+ </Icon>
+ </Button>
+ <Popover placement={isMobile ? 'left' : 'bottom start'} shouldFlip>
+ <Menu
+ onAction={handleAdd}
+ style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }}
+ >
+ {fields
+ .filter(({ name }) => !exclude.includes(name))
+ .map(field => {
+ const isDisabled = !!value.find(({ name }) => name === field.name);
+ return (
+ <MenuItem key={field.name} id={field.name} isDisabled={isDisabled}>
+ {field.label}
+ </MenuItem>
+ );
+ })}
+ </Menu>
+ </Popover>
+ </MenuTrigger>
+ </Row>
+ <Column display={{ xs: 'none', md: 'flex' }} border="right" paddingRight="3" marginRight="6">
+ <List onAction={handleAdd}>
+ {fields
+ .filter(({ name }) => !exclude.includes(name))
+ .map(field => {
+ const isDisabled = !!value.find(({ name }) => name === field.name);
+ return (
+ <ListItem key={field.name} id={field.name} isDisabled={isDisabled}>
+ {field.label}
+ </ListItem>
+ );
+ })}
+ </List>
+ </Column>
+ <Column overflow="auto" gapY="4" style={{ contain: 'layout' }}>
+ {value.map(filter => {
+ return (
+ <FilterRecord
+ key={filter.name}
+ websiteId={websiteId}
+ type={filter.name}
+ startDate={startDate}
+ endDate={endDate}
+ {...filter}
+ onSelect={handleSelect}
+ onRemove={handleRemove}
+ onChange={handleChange}
+ />
+ );
+ })}
+ {!value.length && <Empty message={formatMessage(messages.nothingSelected)} />}
+ </Column>
+ </Grid>
+ );
+}
diff --git a/src/components/input/FilterBar.tsx b/src/components/input/FilterBar.tsx
new file mode 100644
index 0000000..5a52e56
--- /dev/null
+++ b/src/components/input/FilterBar.tsx
@@ -0,0 +1,155 @@
+import {
+ Button,
+ Dialog,
+ DialogTrigger,
+ Icon,
+ Modal,
+ Row,
+ Text,
+ Tooltip,
+ TooltipTrigger,
+} from '@umami/react-zen';
+import { SegmentEditForm } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditForm';
+import {
+ useFilters,
+ useFormat,
+ useMessages,
+ useNavigation,
+ useWebsiteSegmentQuery,
+} from '@/components/hooks';
+import { Bookmark, X } from '@/components/icons';
+import { isSearchOperator } from '@/lib/params';
+
+export function FilterBar({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const { formatValue } = useFormat();
+ const {
+ router,
+ pathname,
+ updateParams,
+ replaceParams,
+ query: { segment, cohort },
+ } = useNavigation();
+ const { filters, operatorLabels } = useFilters();
+ const { data, isLoading } = useWebsiteSegmentQuery(websiteId, segment || cohort);
+ const canSaveSegment = filters.length > 0 && !segment && !cohort && !pathname.includes('/share');
+
+ const handleCloseFilter = (param: string) => {
+ router.push(updateParams({ [param]: undefined }));
+ };
+
+ const handleResetFilter = () => {
+ router.push(replaceParams());
+ };
+
+ const handleSegmentRemove = (type: string) => {
+ router.push(updateParams({ [type]: undefined }));
+ };
+
+ if (!filters.length && !segment && !cohort) {
+ return null;
+ }
+
+ return (
+ <Row gap alignItems="center" justifyContent="space-between" padding="2" backgroundColor="3">
+ <Row alignItems="center" gap="2" wrap="wrap">
+ {segment && !isLoading && (
+ <FilterItem
+ name="segment"
+ label={formatMessage(labels.segment)}
+ value={data?.name || segment}
+ operator={operatorLabels.eq}
+ onRemove={() => handleSegmentRemove('segment')}
+ />
+ )}
+ {cohort && !isLoading && (
+ <FilterItem
+ name="cohort"
+ label={formatMessage(labels.cohort)}
+ value={data?.name || cohort}
+ operator={operatorLabels.eq}
+ onRemove={() => handleSegmentRemove('cohort')}
+ />
+ )}
+ {filters.map(filter => {
+ const { name, label, operator, value } = filter;
+ const paramValue = isSearchOperator(operator) ? value : formatValue(value, name);
+
+ return (
+ <FilterItem
+ key={name}
+ name={name}
+ label={label}
+ operator={operatorLabels[operator]}
+ value={paramValue}
+ onRemove={(name: string) => handleCloseFilter(name)}
+ />
+ );
+ })}
+ </Row>
+ <Row alignItems="center">
+ <DialogTrigger>
+ {canSaveSegment && (
+ <TooltipTrigger delay={0}>
+ <Button variant="zero">
+ <Icon>
+ <Bookmark />
+ </Icon>
+ </Button>
+ <Tooltip>
+ <Text>{formatMessage(labels.saveSegment)}</Text>
+ </Tooltip>
+ </TooltipTrigger>
+ )}
+ <Modal>
+ <Dialog title={formatMessage(labels.segment)} style={{ width: 800, minHeight: 300 }}>
+ {({ close }) => {
+ return <SegmentEditForm websiteId={websiteId} onClose={close} filters={filters} />;
+ }}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ <TooltipTrigger delay={0}>
+ <Button variant="zero" onPress={handleResetFilter}>
+ <Icon>
+ <X />
+ </Icon>
+ </Button>
+ <Tooltip>
+ <Text>{formatMessage(labels.clearAll)}</Text>
+ </Tooltip>
+ </TooltipTrigger>
+ </Row>
+ </Row>
+ );
+}
+
+const FilterItem = ({ name, label, operator, value, onRemove }) => {
+ return (
+ <Row
+ border
+ padding="2"
+ color
+ backgroundColor
+ borderRadius
+ alignItems="center"
+ justifyContent="space-between"
+ theme="dark"
+ >
+ <Row alignItems="center" gap="4">
+ <Row alignItems="center" gap="2">
+ <Text color="12" weight="bold">
+ {label}
+ </Text>
+ <Text color="11">{operator}</Text>
+ <Text color="12" weight="bold">
+ {value}
+ </Text>
+ </Row>
+ <Icon onClick={() => onRemove(name)} size="xs" style={{ cursor: 'pointer' }}>
+ <X />
+ </Icon>
+ </Row>
+ </Row>
+ );
+};
diff --git a/src/components/input/FilterButtons.tsx b/src/components/input/FilterButtons.tsx
new file mode 100644
index 0000000..ff37fb1
--- /dev/null
+++ b/src/components/input/FilterButtons.tsx
@@ -0,0 +1,33 @@
+import { Box, ToggleGroup, ToggleGroupItem } from '@umami/react-zen';
+import { useState } from 'react';
+
+export interface FilterButtonsProps {
+ items: { id: string; label: string }[];
+ value: string;
+ onChange?: (value: string) => void;
+}
+
+export function FilterButtons({ items, value, onChange }: FilterButtonsProps) {
+ const [selected, setSelected] = useState(value);
+
+ const handleChange = (value: string) => {
+ setSelected(value);
+ onChange?.(value);
+ };
+
+ return (
+ <Box>
+ <ToggleGroup
+ value={[selected]}
+ onChange={e => handleChange(e[0])}
+ disallowEmptySelection={true}
+ >
+ {items.map(({ id, label }) => (
+ <ToggleGroupItem key={id} id={id}>
+ {label}
+ </ToggleGroupItem>
+ ))}
+ </ToggleGroup>
+ </Box>
+ );
+}
diff --git a/src/components/input/FilterEditForm.tsx b/src/components/input/FilterEditForm.tsx
new file mode 100644
index 0000000..44f4384
--- /dev/null
+++ b/src/components/input/FilterEditForm.tsx
@@ -0,0 +1,95 @@
+import { Button, Column, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { useState } from 'react';
+import { useFilters, useMessages, useMobile, useNavigation } from '@/components/hooks';
+import { FieldFilters } from '@/components/input/FieldFilters';
+import { SegmentFilters } from '@/components/input/SegmentFilters';
+
+export interface FilterEditFormProps {
+ websiteId?: string;
+ onChange?: (params: { filters: any[]; segment?: string; cohort?: string }) => void;
+ onClose?: () => void;
+}
+
+export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormProps) {
+ const {
+ query: { segment, cohort },
+ pathname,
+ } = useNavigation();
+ const { filters } = useFilters();
+ const { formatMessage, labels } = useMessages();
+ const [currentFilters, setCurrentFilters] = useState(filters);
+ const [currentSegment, setCurrentSegment] = useState(segment);
+ const [currentCohort, setCurrentCohort] = useState(cohort);
+ const { isMobile } = useMobile();
+ const excludeFilters = pathname.includes('/pixels') || pathname.includes('/links');
+
+ const handleReset = () => {
+ setCurrentFilters([]);
+ setCurrentSegment(undefined);
+ setCurrentCohort(undefined);
+ };
+
+ const handleSave = () => {
+ onChange?.({
+ filters: currentFilters.filter(f => f.value),
+ segment: currentSegment,
+ cohort: currentCohort,
+ });
+ onClose?.();
+ };
+
+ const handleSegmentChange = (id: string, type: string) => {
+ setCurrentSegment(type === 'segment' ? id : undefined);
+ setCurrentCohort(type === 'cohort' ? id : undefined);
+ };
+
+ return (
+ <Column width={isMobile ? 'auto' : '800px'} gap="6">
+ <Column minHeight="500px">
+ <Tabs>
+ <TabList>
+ <Tab id="fields">{formatMessage(labels.fields)}</Tab>
+ {!excludeFilters && (
+ <>
+ <Tab id="segments">{formatMessage(labels.segments)}</Tab>
+ <Tab id="cohorts">{formatMessage(labels.cohorts)}</Tab>
+ </>
+ )}
+ </TabList>
+ <TabPanel id="fields">
+ <FieldFilters
+ websiteId={websiteId}
+ value={currentFilters}
+ onChange={setCurrentFilters}
+ exclude={excludeFilters ? ['path', 'title', 'hostname', 'tag', 'event'] : []}
+ />
+ </TabPanel>
+ <TabPanel id="segments">
+ <SegmentFilters
+ websiteId={websiteId}
+ segmentId={currentSegment}
+ onChange={handleSegmentChange}
+ />
+ </TabPanel>
+ <TabPanel id="cohorts">
+ <SegmentFilters
+ type="cohort"
+ websiteId={websiteId}
+ segmentId={currentCohort}
+ onChange={handleSegmentChange}
+ />
+ </TabPanel>
+ </Tabs>
+ </Column>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Button onPress={handleReset}>{formatMessage(labels.reset)}</Button>
+ <Row alignItems="center" justifyContent="flex-end" gridColumn="span 2" gap>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <Button variant="primary" onPress={handleSave}>
+ {formatMessage(labels.apply)}
+ </Button>
+ </Row>
+ </Row>
+ </Column>
+ );
+}
diff --git a/src/components/input/LanguageButton.tsx b/src/components/input/LanguageButton.tsx
new file mode 100644
index 0000000..ac43dcb
--- /dev/null
+++ b/src/components/input/LanguageButton.tsx
@@ -0,0 +1,41 @@
+import { Button, Dialog, Grid, Icon, MenuTrigger, Popover, Text } from '@umami/react-zen';
+import { Globe } from 'lucide-react';
+import { useLocale } from '@/components/hooks';
+import { languages } from '@/lib/lang';
+
+export function LanguageButton() {
+ const { locale, saveLocale } = useLocale();
+ const items = Object.keys(languages).map(key => ({ ...languages[key], value: key }));
+
+ function handleSelect(value: string) {
+ saveLocale(value);
+ }
+
+ return (
+ <MenuTrigger key="language">
+ <Button variant="quiet">
+ <Icon>
+ <Globe />
+ </Icon>
+ </Button>
+ <Popover placement="bottom end">
+ <Dialog variant="menu">
+ <Grid columns="repeat(3, minmax(200px, 1fr))" overflow="hidden">
+ {items.map(({ value, label }) => {
+ return (
+ <Button key={value} variant="quiet" onPress={() => handleSelect(value)}>
+ <Text
+ weight={value === locale ? 'bold' : 'medium'}
+ color={value === locale ? undefined : 'muted'}
+ >
+ {label}
+ </Text>
+ </Button>
+ );
+ })}
+ </Grid>
+ </Dialog>
+ </Popover>
+ </MenuTrigger>
+ );
+}
diff --git a/src/components/input/LookupField.tsx b/src/components/input/LookupField.tsx
new file mode 100644
index 0000000..c1d419f
--- /dev/null
+++ b/src/components/input/LookupField.tsx
@@ -0,0 +1,65 @@
+import { ComboBox, type ComboBoxProps, ListItem, Loading, useDebounce } from '@umami/react-zen';
+import { endOfDay, subMonths } from 'date-fns';
+import { type SetStateAction, useMemo, useState } from 'react';
+import { Empty } from '@/components/common/Empty';
+import { useMessages, useWebsiteValuesQuery } from '@/components/hooks';
+
+export interface LookupFieldProps extends ComboBoxProps {
+ websiteId: string;
+ type: string;
+ value: string;
+ onChange: (value: string) => void;
+}
+
+export function LookupField({ websiteId, type, value, onChange, ...props }: LookupFieldProps) {
+ const { formatMessage, messages } = useMessages();
+ const [search, setSearch] = useState(value);
+ const searchValue = useDebounce(search, 300);
+ const startDate = subMonths(endOfDay(new Date()), 6);
+ const endDate = endOfDay(new Date());
+
+ const { data, isLoading } = useWebsiteValuesQuery({
+ websiteId,
+ type,
+ search: searchValue,
+ startDate,
+ endDate,
+ });
+
+ const items: string[] = useMemo(() => {
+ return data?.map(({ value }) => value) || [];
+ }, [data]);
+
+ const handleSearch = (value: SetStateAction<string>) => {
+ setSearch(value);
+ };
+
+ return (
+ <ComboBox
+ aria-label="LookupField"
+ {...props}
+ items={items}
+ inputValue={value}
+ onInputChange={value => {
+ handleSearch(value);
+ onChange?.(value);
+ }}
+ formValue="text"
+ allowsEmptyCollection
+ allowsCustomValue
+ renderEmptyState={() =>
+ isLoading ? (
+ <Loading placement="center" icon="dots" />
+ ) : (
+ <Empty message={formatMessage(messages.noResultsFound)} />
+ )
+ }
+ >
+ {items.map(item => (
+ <ListItem key={item} id={item}>
+ {item}
+ </ListItem>
+ ))}
+ </ComboBox>
+ );
+}
diff --git a/src/components/input/MenuButton.tsx b/src/components/input/MenuButton.tsx
new file mode 100644
index 0000000..bac307f
--- /dev/null
+++ b/src/components/input/MenuButton.tsx
@@ -0,0 +1,32 @@
+import { Button, DialogTrigger, Icon, Menu, Popover } from '@umami/react-zen';
+import type { Key, ReactNode } from 'react';
+import { Ellipsis } from '@/components/icons';
+
+export function MenuButton({
+ children,
+ onAction,
+ isDisabled,
+}: {
+ children: ReactNode;
+ onAction?: (action: string) => void;
+ isDisabled?: boolean;
+}) {
+ const handleAction = (key: Key) => {
+ onAction?.(key.toString());
+ };
+
+ return (
+ <DialogTrigger>
+ <Button variant="quiet" isDisabled={isDisabled}>
+ <Icon>
+ <Ellipsis />
+ </Icon>
+ </Button>
+ <Popover placement="bottom start">
+ <Menu aria-label="menu" onAction={handleAction} style={{ minWidth: '140px' }}>
+ {children}
+ </Menu>
+ </Popover>
+ </DialogTrigger>
+ );
+}
diff --git a/src/components/input/MobileMenuButton.tsx b/src/components/input/MobileMenuButton.tsx
new file mode 100644
index 0000000..5e59cbb
--- /dev/null
+++ b/src/components/input/MobileMenuButton.tsx
@@ -0,0 +1,17 @@
+import { Button, Dialog, type DialogProps, DialogTrigger, Icon, Modal } from '@umami/react-zen';
+import { Menu } from '@/components/icons';
+
+export function MobileMenuButton(props: DialogProps) {
+ return (
+ <DialogTrigger>
+ <Button>
+ <Icon>
+ <Menu />
+ </Icon>
+ </Button>
+ <Modal placement="left" offset="80px">
+ <Dialog variant="sheet" {...props} />
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/components/input/MonthFilter.tsx b/src/components/input/MonthFilter.tsx
new file mode 100644
index 0000000..dec64b0
--- /dev/null
+++ b/src/components/input/MonthFilter.tsx
@@ -0,0 +1,18 @@
+import { useDateRange, useNavigation } from '@/components/hooks';
+import { getMonthDateRangeValue } from '@/lib/date';
+import { MonthSelect } from './MonthSelect';
+
+export function MonthFilter() {
+ const { router, updateParams } = useNavigation();
+ const {
+ dateRange: { startDate },
+ } = useDateRange();
+
+ const handleMonthSelect = (date: Date) => {
+ const range = getMonthDateRangeValue(date);
+
+ router.push(updateParams({ date: range, offset: undefined }));
+ };
+
+ return <MonthSelect date={startDate} onChange={handleMonthSelect} />;
+}
diff --git a/src/components/input/MonthSelect.tsx b/src/components/input/MonthSelect.tsx
new file mode 100644
index 0000000..241634e
--- /dev/null
+++ b/src/components/input/MonthSelect.tsx
@@ -0,0 +1,47 @@
+import { ListItem, Row, Select } from '@umami/react-zen';
+import { useLocale } from '@/components/hooks';
+import { formatDate } from '@/lib/date';
+
+export function MonthSelect({ date = new Date(), onChange }) {
+ const { locale } = useLocale();
+ const month = date.getMonth();
+ const year = date.getFullYear();
+ const currentYear = new Date().getFullYear();
+
+ const months = [...Array(12)].map((_, i) => i);
+ const years = [...Array(10)].map((_, i) => currentYear - i);
+
+ const handleMonthChange = (month: number) => {
+ const d = new Date(date);
+ d.setMonth(month);
+ onChange?.(d);
+ };
+ const handleYearChange = (year: number) => {
+ const d = new Date(date);
+ d.setFullYear(year);
+ onChange?.(d);
+ };
+
+ return (
+ <Row gap>
+ <Select value={month} onChange={handleMonthChange}>
+ {months.map(m => {
+ return (
+ <ListItem id={m} key={m}>
+ {formatDate(new Date(year, m, 1), 'MMMM', locale)}
+ </ListItem>
+ );
+ })}
+ </Select>
+ <Select value={year} onChange={handleYearChange}>
+ {years.map(y => {
+ return (
+ <ListItem id={y} key={y}>
+ {y}
+ </ListItem>
+ );
+ })}
+ </Select>
+ </Row>
+ );
+}
diff --git a/src/components/input/NavButton.tsx b/src/components/input/NavButton.tsx
new file mode 100644
index 0000000..ab77ef0
--- /dev/null
+++ b/src/components/input/NavButton.tsx
@@ -0,0 +1,188 @@
+import {
+ Column,
+ Icon,
+ IconLabel,
+ Menu,
+ MenuItem,
+ MenuSection,
+ MenuSeparator,
+ MenuTrigger,
+ Popover,
+ Pressable,
+ Row,
+ SubmenuTrigger,
+ Text,
+} from '@umami/react-zen';
+import { ArrowRight } from 'lucide-react';
+import type { Key } from 'react';
+import {
+ useConfig,
+ useLoginQuery,
+ useMessages,
+ useMobile,
+ useNavigation,
+} from '@/components/hooks';
+import {
+ BookText,
+ ChevronRight,
+ ExternalLink,
+ LifeBuoy,
+ LockKeyhole,
+ LogOut,
+ Settings,
+ User,
+ Users,
+} from '@/components/icons';
+import { Switch } from '@/components/svg';
+import { DOCS_URL, LAST_TEAM_CONFIG } from '@/lib/constants';
+import { removeItem } from '@/lib/storage';
+
+export interface TeamsButtonProps {
+ showText?: boolean;
+ onAction?: (id: any) => void;
+}
+
+export function NavButton({ showText = true }: TeamsButtonProps) {
+ const { user } = useLoginQuery();
+ const { cloudMode } = useConfig();
+ const { formatMessage, labels } = useMessages();
+ const { teamId, router } = useNavigation();
+ const { isMobile } = useMobile();
+ const team = user?.teams?.find(({ id }) => id === teamId);
+ const selectedKeys = new Set([teamId || 'user']);
+ const label = teamId ? team?.name : user.username;
+
+ const getUrl = (url: string) => {
+ return cloudMode ? `${process.env.cloudUrl}${url}` : url;
+ };
+
+ const handleAction = async (key: Key) => {
+ if (key === 'user') {
+ removeItem(LAST_TEAM_CONFIG);
+ if (cloudMode) {
+ window.location.href = '/';
+ } else {
+ router.push('/');
+ }
+ }
+ };
+
+ return (
+ <MenuTrigger>
+ <Pressable>
+ <Row
+ alignItems="center"
+ justifyContent="space-between"
+ flexGrow={1}
+ padding
+ border
+ borderRadius
+ shadow="1"
+ maxHeight="40px"
+ role="button"
+ style={{ cursor: 'pointer', textWrap: 'nowrap', overflow: 'hidden', outline: 'none' }}
+ >
+ <Row alignItems="center" position="relative" gap maxHeight="40px">
+ <Icon>{teamId ? <Users /> : <User />}</Icon>
+ {showText && <Text>{label}</Text>}
+ </Row>
+ {showText && (
+ <Icon rotate={90} size="sm">
+ <ChevronRight />
+ </Icon>
+ )}
+ </Row>
+ </Pressable>
+ <Popover placement="bottom start">
+ <Column minWidth="300px">
+ <Menu autoFocus="last">
+ <SubmenuTrigger>
+ <MenuItem id="teams" showChecked={false} showSubMenuIcon>
+ <IconLabel icon={<Switch />} label={formatMessage(labels.switchAccount)} />
+ </MenuItem>
+ <Popover placement={isMobile ? 'bottom start' : 'right top'}>
+ <Column minWidth="300px">
+ <Menu selectionMode="single" selectedKeys={selectedKeys} onAction={handleAction}>
+ <MenuSection title={formatMessage(labels.myAccount)}>
+ <MenuItem id="user">
+ <IconLabel icon={<User />} label={user.username} />
+ </MenuItem>
+ </MenuSection>
+ <MenuSeparator />
+ <MenuSection title={formatMessage(labels.teams)}>
+ {user?.teams?.map(({ id, name }) => (
+ <MenuItem key={id} id={id} href={getUrl(`/teams/${id}`)}>
+ <IconLabel icon={<Users />}>
+ <Text wrap="nowrap">{name}</Text>
+ </IconLabel>
+ </MenuItem>
+ ))}
+ {user?.teams?.length === 0 && (
+ <MenuItem id="manage-teams">
+ <a href="/settings/teams" style={{ width: '100%' }}>
+ <Row alignItems="center" justifyContent="space-between" gap>
+ <Text align="center">Manage teams</Text>
+ <Icon>
+ <ArrowRight />
+ </Icon>
+ </Row>
+ </a>
+ </MenuItem>
+ )}
+ </MenuSection>
+ </Menu>
+ </Column>
+ </Popover>
+ </SubmenuTrigger>
+ <MenuSeparator />
+ <MenuItem
+ id="settings"
+ href={getUrl('/settings')}
+ icon={<Settings />}
+ label={formatMessage(labels.settings)}
+ />
+ {cloudMode && (
+ <>
+ <MenuItem
+ id="docs"
+ href={DOCS_URL}
+ target="_blank"
+ icon={<BookText />}
+ label={formatMessage(labels.documentation)}
+ >
+ <Icon color="muted">
+ <ExternalLink />
+ </Icon>
+ </MenuItem>
+ <MenuItem
+ id="support"
+ href={getUrl('/settings/support')}
+ icon={<LifeBuoy />}
+ label={formatMessage(labels.support)}
+ />
+ </>
+ )}
+ {!cloudMode && user.isAdmin && (
+ <>
+ <MenuSeparator />
+ <MenuItem
+ id="/admin"
+ href="/admin"
+ icon={<LockKeyhole />}
+ label={formatMessage(labels.admin)}
+ />
+ </>
+ )}
+ <MenuSeparator />
+ <MenuItem
+ id="logout"
+ href={getUrl('/logout')}
+ icon={<LogOut />}
+ label={formatMessage(labels.logout)}
+ />
+ </Menu>
+ </Column>
+ </Popover>
+ </MenuTrigger>
+ );
+}
diff --git a/src/components/input/PanelButton.tsx b/src/components/input/PanelButton.tsx
new file mode 100644
index 0000000..500c40c
--- /dev/null
+++ b/src/components/input/PanelButton.tsx
@@ -0,0 +1,19 @@
+import { Button, type ButtonProps, Icon } from '@umami/react-zen';
+import { useGlobalState } from '@/components/hooks';
+import { PanelLeft } from '@/components/icons';
+
+export function PanelButton(props: ButtonProps) {
+ const [isCollapsed, setIsCollapsed] = useGlobalState('sidenav-collapsed');
+ return (
+ <Button
+ onPress={() => setIsCollapsed(!isCollapsed)}
+ variant="zero"
+ {...props}
+ style={{ padding: 0 }}
+ >
+ <Icon strokeColor="muted">
+ <PanelLeft />
+ </Icon>
+ </Button>
+ );
+}
diff --git a/src/components/input/PreferencesButton.tsx b/src/components/input/PreferencesButton.tsx
new file mode 100644
index 0000000..710a7fa
--- /dev/null
+++ b/src/components/input/PreferencesButton.tsx
@@ -0,0 +1,32 @@
+import { Button, Column, DialogTrigger, Icon, Label, Popover } from '@umami/react-zen';
+import { DateRangeSetting } from '@/app/(main)/settings/preferences/DateRangeSetting';
+import { TimezoneSetting } from '@/app/(main)/settings/preferences/TimezoneSetting';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { Settings } from '@/components/icons';
+
+export function PreferencesButton() {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogTrigger>
+ <Button variant="quiet">
+ <Icon>
+ <Settings />
+ </Icon>
+ </Button>
+ <Popover placement="bottom end">
+ <Panel gap="3">
+ <Column>
+ <Label>{formatMessage(labels.timezone)}</Label>
+ <TimezoneSetting />
+ </Column>
+ <Column>
+ <Label>{formatMessage(labels.defaultDateRange)}</Label>
+ <DateRangeSetting />
+ </Column>
+ </Panel>
+ </Popover>
+ </DialogTrigger>
+ );
+}
diff --git a/src/components/input/ProfileButton.tsx b/src/components/input/ProfileButton.tsx
new file mode 100644
index 0000000..505cd88
--- /dev/null
+++ b/src/components/input/ProfileButton.tsx
@@ -0,0 +1,74 @@
+import {
+ Button,
+ Icon,
+ Menu,
+ MenuItem,
+ MenuSection,
+ MenuSeparator,
+ MenuTrigger,
+ Popover,
+ Row,
+ Text,
+} from '@umami/react-zen';
+import { Fragment } from 'react';
+import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
+import { LockKeyhole, LogOut, UserCircle } from '@/components/icons';
+
+export function ProfileButton() {
+ const { formatMessage, labels } = useMessages();
+ const { user } = useLoginQuery();
+ const { renderUrl } = useNavigation();
+
+ const items = [
+ {
+ id: 'settings',
+ label: formatMessage(labels.profile),
+ path: renderUrl('/settings/profile'),
+ icon: <UserCircle />,
+ },
+ user.isAdmin &&
+ !process.env.cloudMode && {
+ id: 'admin',
+ label: formatMessage(labels.admin),
+ path: '/admin',
+ icon: <LockKeyhole />,
+ },
+ {
+ id: 'logout',
+ label: formatMessage(labels.logout),
+ path: '/logout',
+ icon: <LogOut />,
+ separator: true,
+ },
+ ].filter(n => n);
+
+ return (
+ <MenuTrigger>
+ <Button data-test="button-profile" variant="quiet">
+ <Icon>
+ <UserCircle />
+ </Icon>
+ </Button>
+ <Popover placement="bottom end">
+ <Menu autoFocus="last">
+ <MenuSection title={user.username}>
+ <MenuSeparator />
+ {items.map(({ id, path, label, icon, separator }) => {
+ return (
+ <Fragment key={id}>
+ {separator && <MenuSeparator />}
+ <MenuItem id={id} href={path}>
+ <Row alignItems="center" gap>
+ <Icon>{icon}</Icon>
+ <Text>{label}</Text>
+ </Row>
+ </MenuItem>
+ </Fragment>
+ );
+ })}
+ </MenuSection>
+ </Menu>
+ </Popover>
+ </MenuTrigger>
+ );
+}
diff --git a/src/components/input/RefreshButton.tsx b/src/components/input/RefreshButton.tsx
new file mode 100644
index 0000000..b52f830
--- /dev/null
+++ b/src/components/input/RefreshButton.tsx
@@ -0,0 +1,32 @@
+import { Icon, LoadingButton, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { RefreshCw } from '@/components/icons';
+import { setWebsiteDateRange } from '@/store/websites';
+
+export function RefreshButton({
+ websiteId,
+ isLoading,
+}: {
+ websiteId: string;
+ isLoading?: boolean;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { dateRange } = useDateRange();
+
+ function handleClick() {
+ if (!isLoading && dateRange) {
+ setWebsiteDateRange(websiteId, dateRange);
+ }
+ }
+
+ return (
+ <TooltipTrigger>
+ <LoadingButton isLoading={isLoading} onPress={handleClick}>
+ <Icon>
+ <RefreshCw />
+ </Icon>
+ </LoadingButton>
+ <Tooltip>{formatMessage(labels.refresh)}</Tooltip>
+ </TooltipTrigger>
+ );
+}
diff --git a/src/components/input/ReportEditButton.tsx b/src/components/input/ReportEditButton.tsx
new file mode 100644
index 0000000..b333077
--- /dev/null
+++ b/src/components/input/ReportEditButton.tsx
@@ -0,0 +1,99 @@
+import {
+ AlertDialog,
+ Button,
+ Icon,
+ Menu,
+ MenuItem,
+ MenuTrigger,
+ Modal,
+ Popover,
+ Row,
+ Text,
+} from '@umami/react-zen';
+import { type ReactNode, useState } from 'react';
+import { useMessages } from '@/components/hooks';
+import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery';
+import { Edit, MoreHorizontal, Trash } from '@/components/icons';
+
+export function ReportEditButton({
+ id,
+ name,
+ type,
+ children,
+ onDelete,
+}: {
+ id: string;
+ name: string;
+ type: string;
+ onDelete?: () => void;
+ children: ({ close }: { close: () => void }) => ReactNode;
+}) {
+ const { formatMessage, labels, messages } = useMessages();
+ const [showEdit, setShowEdit] = useState(false);
+ const [showDelete, setShowDelete] = useState(false);
+ const { mutateAsync, touch } = useDeleteQuery(`/reports/${id}`);
+
+ const handleAction = (id: any) => {
+ if (id === 'edit') {
+ setShowEdit(true);
+ } else if (id === 'delete') {
+ setShowDelete(true);
+ }
+ };
+
+ const handleClose = () => {
+ setShowEdit(false);
+ setShowDelete(false);
+ };
+
+ const handleDelete = async () => {
+ await mutateAsync(null, {
+ onSuccess: async () => {
+ touch(`reports:${type}`);
+ setShowDelete(false);
+ onDelete?.();
+ },
+ });
+ };
+
+ return (
+ <>
+ <MenuTrigger>
+ <Button variant="quiet">
+ <Icon>
+ <MoreHorizontal />
+ </Icon>
+ </Button>
+ <Popover placement="bottom">
+ <Menu onAction={handleAction}>
+ <MenuItem id="edit">
+ <Icon>
+ <Edit />
+ </Icon>
+ <Text>{formatMessage(labels.edit)}</Text>
+ </MenuItem>
+ <MenuItem id="delete">
+ <Icon>
+ <Trash />
+ </Icon>
+ <Text>{formatMessage(labels.delete)}</Text>
+ </MenuItem>
+ </Menu>
+ </Popover>
+ </MenuTrigger>
+ <Modal isOpen={showEdit || showDelete} isDismissable={true}>
+ {showEdit && children({ close: handleClose })}
+ {showDelete && (
+ <AlertDialog
+ title={formatMessage(labels.delete)}
+ onConfirm={handleDelete}
+ onCancel={handleClose}
+ isDanger
+ >
+ <Row gap="1">{formatMessage(messages.confirmDelete, { target: name })}</Row>
+ </AlertDialog>
+ )}
+ </Modal>
+ </>
+ );
+}
diff --git a/src/components/input/SegmentFilters.tsx b/src/components/input/SegmentFilters.tsx
new file mode 100644
index 0000000..f03a1de
--- /dev/null
+++ b/src/components/input/SegmentFilters.tsx
@@ -0,0 +1,42 @@
+import { IconLabel, List, ListItem } from '@umami/react-zen';
+import { Empty } from '@/components/common/Empty';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useWebsiteSegmentsQuery } from '@/components/hooks';
+import { ChartPie, UserPlus } from '@/components/icons';
+
+export interface SegmentFiltersProps {
+ websiteId: string;
+ segmentId: string;
+ type?: string;
+ onChange?: (id: string, type: string) => void;
+}
+
+export function SegmentFilters({
+ websiteId,
+ segmentId,
+ type = 'segment',
+ onChange,
+}: SegmentFiltersProps) {
+ const { data, isLoading, isFetching } = useWebsiteSegmentsQuery(websiteId, { type });
+
+ const handleChange = (id: string) => {
+ onChange?.(id, type);
+ };
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} overflowY="auto">
+ {data?.data?.length === 0 && <Empty />}
+ <List selectionMode="single" value={[segmentId]} onChange={id => handleChange(id[0])}>
+ {data?.data?.map(item => {
+ return (
+ <ListItem key={item.id} id={item.id}>
+ <IconLabel icon={type === 'segment' ? <ChartPie /> : <UserPlus />}>
+ {item.name}
+ </IconLabel>
+ </ListItem>
+ );
+ })}
+ </List>
+ </LoadingPanel>
+ );
+}
diff --git a/src/components/input/SegmentSaveButton.tsx b/src/components/input/SegmentSaveButton.tsx
new file mode 100644
index 0000000..5f6cac1
--- /dev/null
+++ b/src/components/input/SegmentSaveButton.tsx
@@ -0,0 +1,26 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
+import { SegmentEditForm } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditForm';
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+
+export function SegmentSaveButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+ <DialogTrigger>
+ <Button variant="primary">
+ <Icon>
+ <Plus />
+ </Icon>
+ <Text>{formatMessage(labels.segment)}</Text>
+ </Button>
+ <Modal>
+ <Dialog title={formatMessage(labels.segment)} style={{ width: 800 }}>
+ {({ close }) => {
+ return <SegmentEditForm websiteId={websiteId} onClose={close} />;
+ }}
+ </Dialog>
+ </Modal>
+ </DialogTrigger>
+ );
+}
diff --git a/src/components/input/SettingsButton.tsx b/src/components/input/SettingsButton.tsx
new file mode 100644
index 0000000..bd51fb5
--- /dev/null
+++ b/src/components/input/SettingsButton.tsx
@@ -0,0 +1,84 @@
+import {
+ Button,
+ Icon,
+ Menu,
+ MenuItem,
+ MenuSection,
+ MenuSeparator,
+ MenuTrigger,
+ Popover,
+} from '@umami/react-zen';
+import type { Key } from 'react';
+import { useConfig, useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
+import {
+ BookText,
+ ExternalLink,
+ LifeBuoy,
+ LockKeyhole,
+ LogOut,
+ Settings,
+ UserCircle,
+} from '@/components/icons';
+import { DOCS_URL } from '@/lib/constants';
+
+export function SettingsButton() {
+ const { formatMessage, labels } = useMessages();
+ const { user } = useLoginQuery();
+ const { router } = useNavigation();
+ const { cloudMode } = useConfig();
+
+ const handleAction = (id: Key) => {
+ const url = id.toString();
+
+ if (cloudMode) {
+ if (url === '/docs') {
+ window.open(DOCS_URL, '_blank');
+ } else {
+ window.location.href = url;
+ }
+ } else {
+ router.push(url);
+ }
+ };
+
+ return (
+ <MenuTrigger>
+ <Button data-test="button-profile" variant="quiet" autoFocus={false}>
+ <Icon>
+ <UserCircle />
+ </Icon>
+ </Button>
+ <Popover placement="bottom end">
+ <Menu autoFocus="last" onAction={handleAction}>
+ <MenuSection title={user.username}>
+ <MenuSeparator />
+ <MenuItem id="/settings" icon={<Settings />} label={formatMessage(labels.settings)} />
+ {!cloudMode && user.isAdmin && (
+ <MenuItem id="/admin" icon={<LockKeyhole />} label={formatMessage(labels.admin)} />
+ )}
+ {cloudMode && (
+ <>
+ <MenuItem
+ id="/docs"
+ icon={<BookText />}
+ label={formatMessage(labels.documentation)}
+ >
+ <Icon color="muted">
+ <ExternalLink />
+ </Icon>
+ </MenuItem>
+ <MenuItem
+ id="/settings/support"
+ icon={<LifeBuoy />}
+ label={formatMessage(labels.support)}
+ />
+ </>
+ )}
+ <MenuSeparator />
+ <MenuItem id="/logout" icon={<LogOut />} label={formatMessage(labels.logout)} />
+ </MenuSection>
+ </Menu>
+ </Popover>
+ </MenuTrigger>
+ );
+}
diff --git a/src/components/input/WebsiteDateFilter.tsx b/src/components/input/WebsiteDateFilter.tsx
new file mode 100644
index 0000000..18b4f13
--- /dev/null
+++ b/src/components/input/WebsiteDateFilter.tsx
@@ -0,0 +1,102 @@
+import { Button, Icon, ListItem, Row, Select, Text } from '@umami/react-zen';
+import { isAfter } from 'date-fns';
+import { useMemo } from 'react';
+import { useDateRange, useDateRangeQuery, useMessages, useNavigation } from '@/components/hooks';
+import { ChevronRight } from '@/components/icons';
+import { getDateRangeValue } from '@/lib/date';
+import { DateFilter } from './DateFilter';
+
+export interface WebsiteDateFilterProps {
+ websiteId: string;
+ compare?: string;
+ showAllTime?: boolean;
+ showButtons?: boolean;
+ allowCompare?: boolean;
+}
+
+export function WebsiteDateFilter({
+ websiteId,
+ showAllTime = true,
+ showButtons = true,
+ allowCompare,
+}: WebsiteDateFilterProps) {
+ const { dateRange, isAllTime, isCustomRange } = useDateRange();
+ const { formatMessage, labels } = useMessages();
+ const {
+ router,
+ updateParams,
+ query: { compare = 'prev', offset = 0 },
+ } = useNavigation();
+ const disableForward = isAllTime || isAfter(dateRange.endDate, new Date());
+ const showCompare = allowCompare && !isAllTime;
+
+ const websiteDateRange = useDateRangeQuery(websiteId);
+
+ const handleChange = (date: string) => {
+ if (date === 'all') {
+ router.push(
+ updateParams({
+ date: `${getDateRangeValue(websiteDateRange.startDate, websiteDateRange.endDate)}:all`,
+ offset: undefined,
+ }),
+ );
+ } else {
+ router.push(updateParams({ date, offset: undefined }));
+ }
+ };
+
+ const handleIncrement = increment => {
+ router.push(updateParams({ offset: Number(offset) + increment }));
+ };
+ const handleSelect = (compare: any) => {
+ router.push(updateParams({ compare }));
+ };
+
+ const dateValue = useMemo(() => {
+ return offset !== 0
+ ? getDateRangeValue(dateRange.startDate, dateRange.endDate)
+ : dateRange.value;
+ }, [dateRange]);
+
+ return (
+ <Row wrap="wrap" gap>
+ {showButtons && !isAllTime && !isCustomRange && (
+ <Row gap="1">
+ <Button onPress={() => handleIncrement(-1)} variant="outline">
+ <Icon rotate={180}>
+ <ChevronRight />
+ </Icon>
+ </Button>
+ <Button onPress={() => handleIncrement(1)} variant="outline" isDisabled={disableForward}>
+ <Icon>
+ <ChevronRight />
+ </Icon>
+ </Button>
+ </Row>
+ )}
+ <Row minWidth="200px">
+ <DateFilter
+ value={dateValue}
+ onChange={handleChange}
+ showAllTime={showAllTime}
+ renderDate={+offset !== 0}
+ />
+ </Row>
+ {showCompare && (
+ <Row alignItems="center" gap>
+ <Text weight="bold">VS</Text>
+ <Row width="200px">
+ <Select
+ value={compare}
+ onChange={handleSelect}
+ popoverProps={{ style: { width: 200 } }}
+ >
+ <ListItem id="prev">{formatMessage(labels.previousPeriod)}</ListItem>
+ <ListItem id="yoy">{formatMessage(labels.previousYear)}</ListItem>
+ </Select>
+ </Row>
+ </Row>
+ )}
+ </Row>
+ );
+}
diff --git a/src/components/input/WebsiteFilterButton.tsx b/src/components/input/WebsiteFilterButton.tsx
new file mode 100644
index 0000000..7db850a
--- /dev/null
+++ b/src/components/input/WebsiteFilterButton.tsx
@@ -0,0 +1,32 @@
+import { useMessages, useNavigation } from '@/components/hooks';
+import { ListFilter } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { FilterEditForm } from '@/components/input/FilterEditForm';
+import { filtersArrayToObject } from '@/lib/params';
+
+export function WebsiteFilterButton({
+ websiteId,
+}: {
+ websiteId: string;
+ position?: 'bottom' | 'top' | 'left' | 'right';
+ alignment?: 'end' | 'center' | 'start';
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { updateParams, router } = useNavigation();
+
+ const handleChange = ({ filters, segment, cohort }: any) => {
+ const params = filtersArrayToObject(filters);
+
+ const url = updateParams({ ...params, segment, cohort });
+
+ router.push(url);
+ };
+
+ return (
+ <DialogButton icon={<ListFilter />} label={formatMessage(labels.filter)} variant="outline">
+ {({ close }) => {
+ return <FilterEditForm websiteId={websiteId} onChange={handleChange} onClose={close} />;
+ }}
+ </DialogButton>
+ );
+}
diff --git a/src/components/input/WebsiteSelect.tsx b/src/components/input/WebsiteSelect.tsx
new file mode 100644
index 0000000..8d81eb9
--- /dev/null
+++ b/src/components/input/WebsiteSelect.tsx
@@ -0,0 +1,74 @@
+import { ListItem, Row, Select, type SelectProps, Text } from '@umami/react-zen';
+import { useState } from 'react';
+import { Empty } from '@/components/common/Empty';
+import {
+ useLoginQuery,
+ useMessages,
+ useUserWebsitesQuery,
+ useWebsiteQuery,
+} from '@/components/hooks';
+
+export function WebsiteSelect({
+ websiteId,
+ teamId,
+ onChange,
+ includeTeams,
+ ...props
+}: {
+ websiteId?: string;
+ teamId?: string;
+ includeTeams?: boolean;
+} & SelectProps) {
+ const { formatMessage, messages } = useMessages();
+ const { data: website } = useWebsiteQuery(websiteId);
+ const [name, setName] = useState<string>(website?.name);
+ const [search, setSearch] = useState('');
+ const { user } = useLoginQuery();
+ const { data, isLoading } = useUserWebsitesQuery(
+ { userId: user?.id, teamId },
+ { search, pageSize: 10, includeTeams },
+ );
+ const listItems: { id: string; name: string }[] = data?.data || [];
+
+ const handleSearch = (value: string) => {
+ setSearch(value);
+ };
+
+ const handleOpenChange = () => {
+ setSearch('');
+ };
+
+ const handleChange = (id: string) => {
+ setName(listItems.find(item => item.id === id)?.name);
+ onChange(id);
+ };
+
+ const renderValue = () => {
+ return (
+ <Row maxWidth="160px">
+ <Text truncate>{name}</Text>
+ </Row>
+ );
+ };
+
+ return (
+ <Select
+ {...props}
+ items={listItems}
+ value={websiteId}
+ isLoading={isLoading}
+ allowSearch={true}
+ searchValue={search}
+ onSearch={handleSearch}
+ onChange={handleChange}
+ onOpenChange={handleOpenChange}
+ renderValue={renderValue}
+ listProps={{
+ renderEmptyState: () => <Empty message={formatMessage(messages.noResultsFound)} />,
+ style: { maxHeight: '400px' },
+ }}
+ >
+ {({ id, name }: any) => <ListItem key={id}>{name}</ListItem>}
+ </Select>
+ );
+}
diff --git a/src/components/messages.ts b/src/components/messages.ts
new file mode 100644
index 0000000..0438c06
--- /dev/null
+++ b/src/components/messages.ts
@@ -0,0 +1,518 @@
+import { defineMessages } from 'react-intl';
+
+export const labels = defineMessages({
+ ok: { id: 'label.ok', defaultMessage: 'OK' },
+ unknown: { id: 'label.unknown', defaultMessage: 'Unknown' },
+ required: { id: 'label.required', defaultMessage: 'Required' },
+ save: { id: 'label.save', defaultMessage: 'Save' },
+ cancel: { id: 'label.cancel', defaultMessage: 'Cancel' },
+ continue: { id: 'label.continue', defaultMessage: 'Continue' },
+ delete: { id: 'label.delete', defaultMessage: 'Delete' },
+ leave: { id: 'label.leave', defaultMessage: 'Leave' },
+ users: { id: 'label.users', defaultMessage: 'Users' },
+ createUser: { id: 'label.create-user', defaultMessage: 'Create user' },
+ deleteUser: { id: 'label.delete-user', defaultMessage: 'Delete user' },
+ username: { id: 'label.username', defaultMessage: 'Username' },
+ password: { id: 'label.password', defaultMessage: 'Password' },
+ role: { id: 'label.role', defaultMessage: 'Role' },
+ user: { id: 'label.user', defaultMessage: 'User' },
+ viewOnly: { id: 'label.view-only', defaultMessage: 'View only' },
+ manage: { id: 'label.manage', defaultMessage: 'Manage' },
+ admin: { id: 'label.admin', defaultMessage: 'Admin' },
+ confirm: { id: 'label.confirm', defaultMessage: 'Confirm' },
+ details: { id: 'label.details', defaultMessage: 'Details' },
+ website: { id: 'label.website', defaultMessage: 'Website' },
+ websites: { id: 'label.websites', defaultMessage: 'Websites' },
+ myWebsites: { id: 'label.my-websites', defaultMessage: 'My websites' },
+ teamWebsites: { id: 'label.team-websites', defaultMessage: 'Team websites' },
+ created: { id: 'label.created', defaultMessage: 'Created' },
+ createdBy: { id: 'label.created-by', defaultMessage: 'Created By' },
+ edit: { id: 'label.edit', defaultMessage: 'Edit' },
+ name: { id: 'label.name', defaultMessage: 'Name' },
+ manager: { id: 'label.manager', defaultMessage: 'Manager' },
+ member: { id: 'label.member', defaultMessage: 'Member' },
+ members: { id: 'label.members', defaultMessage: 'Members' },
+ accessCode: { id: 'label.access-code', defaultMessage: 'Access code' },
+ teamId: { id: 'label.team-id', defaultMessage: 'Team ID' },
+ team: { id: 'label.team', defaultMessage: 'Team' },
+ teamName: { id: 'label.team-name', defaultMessage: 'Team name' },
+ regenerate: { id: 'label.regenerate', defaultMessage: 'Regenerate' },
+ remove: { id: 'label.remove', defaultMessage: 'Remove' },
+ join: { id: 'label.join', defaultMessage: 'Join' },
+ createTeam: { id: 'label.create-team', defaultMessage: 'Create team' },
+ joinTeam: { id: 'label.join-team', defaultMessage: 'Join team' },
+ settings: { id: 'label.settings', defaultMessage: 'Settings' },
+ owner: { id: 'label.owner', defaultMessage: 'Owner' },
+ teamOwner: { id: 'label.team-owner', defaultMessage: 'Team owner' },
+ teamManager: { id: 'label.team-manager', defaultMessage: 'Team manager' },
+ teamMember: { id: 'label.team-member', defaultMessage: 'Team member' },
+ teamViewOnly: { id: 'label.team-view-only', defaultMessage: 'Team view only' },
+ enableShareUrl: { id: 'label.enable-share-url', defaultMessage: 'Enable share URL' },
+ data: { id: 'label.data', defaultMessage: 'Data' },
+ trackingCode: { id: 'label.tracking-code', defaultMessage: 'Tracking code' },
+ shareUrl: { id: 'label.share-url', defaultMessage: 'Share URL' },
+ action: { id: 'label.action', defaultMessage: 'Action' },
+ actions: { id: 'label.actions', defaultMessage: 'Actions' },
+ domain: { id: 'label.domain', defaultMessage: 'Domain' },
+ websiteId: { id: 'label.website-id', defaultMessage: 'Website ID' },
+ resetWebsite: { id: 'label.reset-website', defaultMessage: 'Reset website' },
+ deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' },
+ transferWebsite: { id: 'label.transfer-website', defaultMessage: 'Transfer website' },
+ deleteReport: { id: 'label.delete-report', defaultMessage: 'Delete report' },
+ reset: { id: 'label.reset', defaultMessage: 'Reset' },
+ addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' },
+ addMember: { id: 'label.add-member', defaultMessage: 'Add member' },
+ editMember: { id: 'label.edit-member', defaultMessage: 'Edit member' },
+ removeMember: { id: 'label.remove-member', defaultMessage: 'Remove member' },
+ addDescription: { id: 'label.add-description', defaultMessage: 'Add description' },
+ changePassword: { id: 'label.change-password', defaultMessage: 'Change password' },
+ currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' },
+ newPassword: { id: 'label.new-password', defaultMessage: 'New password' },
+ confirmPassword: { id: 'label.confirm-password', defaultMessage: 'Confirm password' },
+ timezone: { id: 'label.timezone', defaultMessage: 'Timezone' },
+ defaultDateRange: { id: 'label.default-date-range', defaultMessage: 'Default date range' },
+ language: { id: 'label.language', defaultMessage: 'Language' },
+ theme: { id: 'label.theme', defaultMessage: 'Theme' },
+ profile: { id: 'label.profile', defaultMessage: 'Profile' },
+ profiles: { id: 'label.profiles', defaultMessage: 'Profiles' },
+ dashboard: { id: 'label.dashboard', defaultMessage: 'Dashboard' },
+ more: { id: 'label.more', defaultMessage: 'More' },
+ realtime: { id: 'label.realtime', defaultMessage: 'Realtime' },
+ queries: { id: 'label.queries', defaultMessage: 'Queries' },
+ teams: { id: 'label.teams', defaultMessage: 'Teams' },
+ teamSettings: { id: 'label.team-settings', defaultMessage: 'Team settings' },
+ analytics: { id: 'label.analytics', defaultMessage: 'Analytics' },
+ login: { id: 'label.login', defaultMessage: 'Login' },
+ logout: { id: 'label.logout', defaultMessage: 'Logout' },
+ singleDay: { id: 'label.single-day', defaultMessage: 'Single day' },
+ dateRange: { id: 'label.date-range', defaultMessage: 'Date range' },
+ viewDetails: { id: 'label.view-details', defaultMessage: 'View details' },
+ deleteTeam: { id: 'label.delete-team', defaultMessage: 'Delete team' },
+ leaveTeam: { id: 'label.leave-team', defaultMessage: 'Leave team' },
+ refresh: { id: 'label.refresh', defaultMessage: 'Refresh' },
+ page: { id: 'label.page', defaultMessage: 'Page' },
+ pages: { id: 'label.pages', defaultMessage: 'Pages' },
+ entry: { id: 'label.entry', defaultMessage: 'Entry' },
+ exit: { id: 'label.exit', defaultMessage: 'Exit' },
+ referrers: { id: 'label.referrers', defaultMessage: 'Referrers' },
+ screen: { id: 'label.screen', defaultMessage: 'Screen' },
+ screens: { id: 'label.screens', defaultMessage: 'Screens' },
+ browsers: { id: 'label.browsers', defaultMessage: 'Browsers' },
+ os: { id: 'label.os', defaultMessage: 'OS' },
+ devices: { id: 'label.devices', defaultMessage: 'Devices' },
+ countries: { id: 'label.countries', defaultMessage: 'Countries' },
+ languages: { id: 'label.languages', defaultMessage: 'Languages' },
+ tags: { id: 'label.tags', defaultMessage: 'Tags' },
+ segments: { id: 'label.segments', defaultMessage: 'Segments' },
+ cohorts: { id: 'label.cohorts', defaultMessage: 'Cohorts' },
+ count: { id: 'label.count', defaultMessage: 'Count' },
+ average: { id: 'label.average', defaultMessage: 'Average' },
+ sum: { id: 'label.sum', defaultMessage: 'Sum' },
+ event: { id: 'label.event', defaultMessage: 'Event' },
+ events: { id: 'label.events', defaultMessage: 'Events' },
+ eventName: { id: 'label.event-name', defaultMessage: 'Event name' },
+ query: { id: 'label.query', defaultMessage: 'Query' },
+ queryParameters: { id: 'label.query-parameters', defaultMessage: 'Query parameters' },
+ back: { id: 'label.back', defaultMessage: 'Back' },
+ visitors: { id: 'label.visitors', defaultMessage: 'Visitors' },
+ visits: { id: 'label.visits', defaultMessage: 'Visits' },
+ filterCombined: { id: 'label.filter-combined', defaultMessage: 'Combined' },
+ filterRaw: { id: 'label.filter-raw', defaultMessage: 'Raw' },
+ views: { id: 'label.views', defaultMessage: 'Views' },
+ none: { id: 'label.none', defaultMessage: 'None' },
+ clearAll: { id: 'label.clear-all', defaultMessage: 'Clear all' },
+ property: { id: 'label.property', defaultMessage: 'Property' },
+ today: { id: 'label.today', defaultMessage: 'Today' },
+ lastHours: { id: 'label.last-hours', defaultMessage: 'Last {x} hours' },
+ yesterday: { id: 'label.yesterday', defaultMessage: 'Yesterday' },
+ thisWeek: { id: 'label.this-week', defaultMessage: 'This week' },
+ lastDays: { id: 'label.last-days', defaultMessage: 'Last {x} days' },
+ lastMonths: { id: 'label.last-months', defaultMessage: 'Last {x} months' },
+ thisMonth: { id: 'label.this-month', defaultMessage: 'This month' },
+ thisYear: { id: 'label.this-year', defaultMessage: 'This year' },
+ allTime: { id: 'label.all-time', defaultMessage: 'All time' },
+ customRange: { id: 'label.custom-range', defaultMessage: 'Custom range' },
+ selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' },
+ selectRole: { id: 'label.select-role', defaultMessage: 'Select role' },
+ selectDate: { id: 'label.select-date', defaultMessage: 'Select date' },
+ selectFilter: { id: 'label.select-filter', defaultMessage: 'Select filter' },
+ all: { id: 'label.all', defaultMessage: 'All' },
+ session: { id: 'label.session', defaultMessage: 'Session' },
+ sessions: { id: 'label.sessions', defaultMessage: 'Sessions' },
+ distinctId: { id: 'label.distinct-id', defaultMessage: 'Distinct ID' },
+ pageNotFound: { id: 'message.page-not-found', defaultMessage: 'Page not found' },
+ activity: { id: 'label.activity', defaultMessage: 'Activity' },
+ dismiss: { id: 'label.dismiss', defaultMessage: 'Dismiss' },
+ poweredBy: { id: 'label.powered-by', defaultMessage: 'Powered by {name}' },
+ pageViews: { id: 'label.page-views', defaultMessage: 'Page views' },
+ uniqueVisitors: { id: 'label.unique-visitors', defaultMessage: 'Unique visitors' },
+ bounceRate: { id: 'label.bounce-rate', defaultMessage: 'Bounce rate' },
+ viewsPerVisit: { id: 'label.views-per-visit', defaultMessage: 'Views per visit' },
+ visitDuration: { id: 'label.visit-duration', defaultMessage: 'Visit duration' },
+ desktop: { id: 'label.desktop', defaultMessage: 'Desktop' },
+ laptop: { id: 'label.laptop', defaultMessage: 'Laptop' },
+ tablet: { id: 'label.tablet', defaultMessage: 'Tablet' },
+ mobile: { id: 'label.mobile', defaultMessage: 'Mobile' },
+ toggleCharts: { id: 'label.toggle-charts', defaultMessage: 'Toggle charts' },
+ editDashboard: { id: 'label.edit-dashboard', defaultMessage: 'Edit dashboard' },
+ title: { id: 'label.title', defaultMessage: 'Title' },
+ view: { id: 'label.view', defaultMessage: 'View' },
+ cities: { id: 'label.cities', defaultMessage: 'Cities' },
+ regions: { id: 'label.regions', defaultMessage: 'Regions' },
+ reports: { id: 'label.reports', defaultMessage: 'Reports' },
+ eventData: { id: 'label.event-data', defaultMessage: 'Event data' },
+ sessionData: { id: 'label.session-data', defaultMessage: 'Session data' },
+ funnel: { id: 'label.funnel', defaultMessage: 'Funnel' },
+ funnels: { id: 'label.funnels', defaultMessage: 'Funnels' },
+ funnelDescription: {
+ id: 'label.funnel-description',
+ defaultMessage: 'Understand the conversion and drop-off rate of users.',
+ },
+ revenue: { id: 'label.revenue', defaultMessage: 'Revenue' },
+ revenueDescription: {
+ id: 'label.revenue-description',
+ defaultMessage: 'Look into your revenue data and how users are spending.',
+ },
+ attribution: { id: 'label.attribution', defaultMessage: 'Attribution' },
+ attributionDescription: {
+ id: 'label.attribution-description',
+ defaultMessage: 'See how users engage with your marketing and what drives conversions.',
+ },
+ currency: { id: 'label.currency', defaultMessage: 'Currency' },
+ model: { id: 'label.model', defaultMessage: 'Model' },
+ path: { id: 'label.path', defaultMessage: 'Path' },
+ paths: { id: 'label.paths', defaultMessage: 'Paths' },
+ add: { id: 'label.add', defaultMessage: 'Add' },
+ update: { id: 'label.update', defaultMessage: 'Update' },
+ window: { id: 'label.window', defaultMessage: 'Window' },
+ runQuery: { id: 'label.run-query', defaultMessage: 'Run query' },
+ field: { id: 'label.field', defaultMessage: 'Field' },
+ fields: { id: 'label.fields', defaultMessage: 'Fields' },
+ createReport: { id: 'label.create-report', defaultMessage: 'Create report' },
+ description: { id: 'label.description', defaultMessage: 'Description' },
+ untitled: { id: 'label.untitled', defaultMessage: 'Untitled' },
+ type: { id: 'label.type', defaultMessage: 'Type' },
+ filter: { id: 'label.filter', defaultMessage: 'Filter' },
+ filters: { id: 'label.filters', defaultMessage: 'Filters' },
+ breakdown: { id: 'label.breakdown', defaultMessage: 'Breakdown' },
+ true: { id: 'label.true', defaultMessage: 'True' },
+ false: { id: 'label.false', defaultMessage: 'False' },
+ is: { id: 'label.is', defaultMessage: 'Is' },
+ isNot: { id: 'label.is-not', defaultMessage: 'Is not' },
+ isSet: { id: 'label.is-set', defaultMessage: 'Is set' },
+ isNotSet: { id: 'label.is-not-set', defaultMessage: 'Is not set' },
+ greaterThan: { id: 'label.greater-than', defaultMessage: 'Greater than' },
+ lessThan: { id: 'label.less-than', defaultMessage: 'Less than' },
+ greaterThanEquals: { id: 'label.greater-than-equals', defaultMessage: 'Greater than or equals' },
+ lessThanEquals: { id: 'label.less-than-equals', defaultMessage: 'Less than or equals' },
+ contains: { id: 'label.contains', defaultMessage: 'Contains' },
+ doesNotContain: { id: 'label.does-not-contain', defaultMessage: 'Does not contain' },
+ includes: { id: 'label.includes', defaultMessage: 'Includes' },
+ doesNotInclude: { id: 'label.does-not-include', defaultMessage: 'Does not include' },
+ before: { id: 'label.before', defaultMessage: 'Before' },
+ after: { id: 'label.after', defaultMessage: 'After' },
+ isTrue: { id: 'label.is-true', defaultMessage: 'Is true' },
+ isFalse: { id: 'label.is-false', defaultMessage: 'Is false' },
+ exists: { id: 'label.exists', defaultMessage: 'Exists' },
+ doesNotExist: { id: 'label.doest-not-exist', defaultMessage: 'Does not exist' },
+ total: { id: 'label.total', defaultMessage: 'Total' },
+ min: { id: 'label.min', defaultMessage: 'Min' },
+ max: { id: 'label.max', defaultMessage: 'Max' },
+ unique: { id: 'label.unique', defaultMessage: 'Unique' },
+ value: { id: 'label.value', defaultMessage: 'Value' },
+ overview: { id: 'label.overview', defaultMessage: 'Overview' },
+ totalRecords: { id: 'label.total-records', defaultMessage: 'Total records' },
+ insight: { id: 'label.insight', defaultMessage: 'Insight' },
+ insights: { id: 'label.insights', defaultMessage: 'Insights' },
+ insightsDescription: {
+ id: 'label.insights-description',
+ defaultMessage: 'Dive deeper into your data by using segments and filters.',
+ },
+ retention: { id: 'label.retention', defaultMessage: 'Retention' },
+ retentionDescription: {
+ id: 'label.retention-description',
+ defaultMessage: 'Measure your website stickiness by tracking how often users return.',
+ },
+ dropoff: { id: 'label.dropoff', defaultMessage: 'Dropoff' },
+ referrer: { id: 'label.referrer', defaultMessage: 'Referrer' },
+ hostname: { id: 'label.hostname', defaultMessage: 'Hostname' },
+ country: { id: 'label.country', defaultMessage: 'Country' },
+ region: { id: 'label.region', defaultMessage: 'Region' },
+ city: { id: 'label.city', defaultMessage: 'City' },
+ browser: { id: 'label.browser', defaultMessage: 'Browser' },
+ device: { id: 'label.device', defaultMessage: 'Device' },
+ pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' },
+ tag: { id: 'label.tag', defaultMessage: 'Tag' },
+ segment: { id: 'label.segment', defaultMessage: 'Segment' },
+ cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
+ day: { id: 'label.day', defaultMessage: 'Day' },
+ date: { id: 'label.date', defaultMessage: 'Date' },
+ pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
+ create: { id: 'label.create', defaultMessage: 'Create' },
+ search: { id: 'label.search', defaultMessage: 'Search' },
+ numberOfRecords: {
+ id: 'label.number-of-records',
+ defaultMessage: '{x} {x, plural, one {record} other {records}}',
+ },
+ select: { id: 'label.select', defaultMessage: 'Select' },
+ myAccount: { id: 'label.my-account', defaultMessage: 'My account' },
+ transfer: { id: 'label.transfer', defaultMessage: 'Transfer' },
+ transactions: { id: 'label.transactions', defaultMessage: 'Transactions' },
+ uniqueCustomers: { id: 'label.uniqueCustomers', defaultMessage: 'Unique Customers' },
+ viewedPage: {
+ id: 'message.viewed-page',
+ defaultMessage: 'Viewed page',
+ },
+ collectedData: {
+ id: 'message.collected-data',
+ defaultMessage: 'Collected data',
+ },
+ triggeredEvent: {
+ id: 'message.triggered-event',
+ defaultMessage: 'Triggered event',
+ },
+ utm: { id: 'label.utm', defaultMessage: 'UTM' },
+ utmDescription: {
+ id: 'label.utm-description',
+ defaultMessage: 'Track your campaigns through UTM parameters.',
+ },
+ conversionStep: { id: 'label.conversion-step', defaultMessage: 'Conversion step' },
+ conversionRate: { id: 'label.conversion-rate', defaultMessage: 'Conversion rate' },
+ steps: { id: 'label.steps', defaultMessage: 'Steps' },
+ startStep: { id: 'label.start-step', defaultMessage: 'Start Step' },
+ endStep: { id: 'label.end-step', defaultMessage: 'End Step' },
+ addStep: { id: 'label.add-step', defaultMessage: 'Add step' },
+ goal: { id: 'label.goal', defaultMessage: 'Goal' },
+ goals: { id: 'label.goals', defaultMessage: 'Goals' },
+ goalsDescription: {
+ id: 'label.goals-description',
+ defaultMessage: 'Track your goals for pageviews and events.',
+ },
+ journey: { id: 'label.journey', defaultMessage: 'Journey' },
+ journeys: { id: 'label.journeys', defaultMessage: 'Journeys' },
+ journeyDescription: {
+ id: 'label.journey-description',
+ defaultMessage: 'Understand how users navigate through your website.',
+ },
+ compareDates: { id: 'label.compare-dates', defaultMessage: 'Compare dates' },
+ compare: { id: 'label.compare', defaultMessage: 'Compare' },
+ current: { id: 'label.current', defaultMessage: 'Current' },
+ previous: { id: 'label.previous', defaultMessage: 'Previous' },
+ previousPeriod: { id: 'label.previous-period', defaultMessage: 'Previous period' },
+ previousYear: { id: 'label.previous-year', defaultMessage: 'Previous year' },
+ lastSeen: { id: 'label.last-seen', defaultMessage: 'Last seen' },
+ firstSeen: { id: 'label.first-seen', defaultMessage: 'First seen' },
+ properties: { id: 'label.properties', defaultMessage: 'Properties' },
+ channel: { id: 'label.channel', defaultMessage: 'Channel' },
+ channels: { id: 'label.channels', defaultMessage: 'Channels' },
+ sources: { id: 'label.sources', defaultMessage: 'Sources' },
+ medium: { id: 'label.medium', defaultMessage: 'Medium' },
+ campaigns: { id: 'label.campaigns', defaultMessage: 'Campaigns' },
+ content: { id: 'label.content', defaultMessage: 'Content' },
+ terms: { id: 'label.terms', defaultMessage: 'Terms' },
+ direct: { id: 'label.direct', defaultMessage: 'Direct' },
+ referral: { id: 'label.referral', defaultMessage: 'Referral' },
+ affiliate: { id: 'label.affiliate', defaultMessage: 'Affiliate' },
+ email: { id: 'label.email', defaultMessage: 'Email' },
+ sms: { id: 'label.sms', defaultMessage: 'SMS' },
+ organicSearch: { id: 'label.organic-search', defaultMessage: 'Organic search' },
+ organicSocial: { id: 'label.organic-social', defaultMessage: 'Organic social' },
+ organicShopping: { id: 'label.organic-shopping', defaultMessage: 'Organic shopping' },
+ organicVideo: { id: 'label.organic-video', defaultMessage: 'Organic video' },
+ paidAds: { id: 'label.paid-ads', defaultMessage: 'Paid ads' },
+ paidSearch: { id: 'label.paid-search', defaultMessage: 'Paid search' },
+ paidSocial: { id: 'label.paid-social', defaultMessage: 'Paid social' },
+ paidShopping: { id: 'label.paid-shopping', defaultMessage: 'Paid shopping' },
+ paidVideo: { id: 'label.paid-video', defaultMessage: 'Paid video' },
+ grouped: { id: 'label.grouped', defaultMessage: 'Grouped' },
+ other: { id: 'label.other', defaultMessage: 'Other' },
+ boards: { id: 'label.boards', defaultMessage: 'Boards' },
+ apply: { id: 'label.apply', defaultMessage: 'Apply' },
+ link: { id: 'label.link', defaultMessage: 'Link' },
+ links: { id: 'label.links', defaultMessage: 'Links' },
+ pixel: { id: 'label.pixel', defaultMessage: 'Pixel' },
+ pixels: { id: 'label.pixels', defaultMessage: 'Pixels' },
+ addBoard: { id: 'label.add-board', defaultMessage: 'Add board' },
+ addLink: { id: 'label.add-link', defaultMessage: 'Add link' },
+ addPixel: { id: 'label.add-pixel', defaultMessage: 'Add pixel' },
+ maximize: { id: 'label.maximize', defaultMessage: 'Maximize' },
+ remaining: { id: 'label.remaining', defaultMessage: 'Remaining' },
+ conversion: { id: 'label.conversion', defaultMessage: 'Conversion' },
+ firstClick: { id: 'label.first-click', defaultMessage: 'First click' },
+ lastClick: { id: 'label.last-click', defaultMessage: 'Last click' },
+ online: { id: 'label.online', defaultMessage: 'Online' },
+ preferences: { id: 'label.preferences', defaultMessage: 'Preferences' },
+ location: { id: 'label.location', defaultMessage: 'Location' },
+ chart: { id: 'label.chart', defaultMessage: 'Chart' },
+ table: { id: 'label.table', defaultMessage: 'Table' },
+ download: { id: 'label.download', defaultMessage: 'Download' },
+ traffic: { id: 'label.traffic', defaultMessage: 'Traffic' },
+ behavior: { id: 'label.behavior', defaultMessage: 'Behavior' },
+ growth: { id: 'label.growth', defaultMessage: 'Growth' },
+ account: { id: 'label.account', defaultMessage: 'Account' },
+ application: { id: 'label.application', defaultMessage: 'Application' },
+ saveSegment: { id: 'label.save-segment', defaultMessage: 'Save as segment' },
+ saveCohort: { id: 'label.save-cohort', defaultMessage: 'Save as cohort' },
+ analysis: { id: 'label.analysis', defaultMessage: 'Analysis' },
+ destinationUrl: { id: 'label.destination-url', defaultMessage: 'Destination URL' },
+ audience: { id: 'label.audience', defaultMessage: 'Audience' },
+ invalidUrl: { id: 'label.invalid-url', defaultMessage: 'Invalid URL' },
+ environment: { id: 'label.environment', defaultMessage: 'Environment' },
+ criteria: { id: 'label.criteria', defaultMessage: 'Criteria' },
+ share: { id: 'label.share', defaultMessage: 'Share' },
+ support: { id: 'label.support', defaultMessage: 'Support' },
+ documentation: { id: 'label.documentation', defaultMessage: 'Documentation' },
+ switchAccount: { id: 'label.switch-account', defaultMessage: 'Switch account' },
+});
+
+export const messages = defineMessages({
+ error: { id: 'message.error', defaultMessage: 'Something went wrong.' },
+ saved: { id: 'message.saved', defaultMessage: 'Saved successfully.' },
+ noUsers: { id: 'message.no-users', defaultMessage: 'There are no users.' },
+ userDeleted: { id: 'message.user-deleted', defaultMessage: 'User deleted.' },
+ noDataAvailable: { id: 'message.no-data-available', defaultMessage: 'No data available.' },
+ nothingSelected: { id: 'message.nothing-selected', defaultMessage: 'Nothing selected.' },
+ confirmReset: {
+ id: 'message.confirm-reset',
+ defaultMessage: 'Are you sure you want to reset {target}?',
+ },
+ confirmDelete: {
+ id: 'message.confirm-delete',
+ defaultMessage: 'Are you sure you want to delete {target}?',
+ },
+ confirmRemove: {
+ id: 'message.confirm-remove',
+ defaultMessage: 'Are you sure you want to remove {target}?',
+ },
+ confirmLeave: {
+ id: 'message.confirm-leave',
+ defaultMessage: 'Are you sure you want to leave {target}?',
+ },
+ minPasswordLength: {
+ id: 'message.min-password-length',
+ defaultMessage: 'Minimum length of {n} characters',
+ },
+ noTeams: {
+ id: 'message.no-teams',
+ defaultMessage: 'You have not created any teams.',
+ },
+ shareUrl: {
+ id: 'message.share-url',
+ defaultMessage: 'Your website stats are publicly available at the following URL:',
+ },
+ trackingCode: {
+ id: 'message.tracking-code',
+ defaultMessage:
+ 'To track stats for this website, place the following code in the <head>...</head> section of your HTML.',
+ },
+ joinTeamWarning: {
+ id: 'message.team-already-member',
+ defaultMessage: 'You are already a member of the team.',
+ },
+ actionConfirmation: {
+ id: 'message.action-confirmation',
+ defaultMessage: 'Type {confirmation} in the box below to confirm.',
+ },
+ resetWebsite: {
+ id: 'message.reset-website',
+ defaultMessage: 'To reset this website, type {confirmation} in the box below to confirm.',
+ },
+ invalidDomain: {
+ id: 'message.invalid-domain',
+ defaultMessage: 'Invalid domain. Do not include http/https.',
+ },
+ resetWebsiteWarning: {
+ id: 'message.reset-website-warning',
+ defaultMessage:
+ 'All statistics for this website will be deleted, but your settings will remain intact.',
+ },
+ deleteWebsiteWarning: {
+ id: 'message.delete-website-warning',
+ defaultMessage: 'All website data will be deleted.',
+ },
+ deleteTeamWarning: {
+ id: 'message.delete-team-warning',
+ defaultMessage: 'Deleting a team will also delete all team websites.',
+ },
+ noResultsFound: {
+ id: 'message.no-results-found',
+ defaultMessage: 'No results found.',
+ },
+ noWebsitesConfigured: {
+ id: 'message.no-websites-configured',
+ defaultMessage: 'You do not have any websites configured.',
+ },
+ noTeamWebsites: {
+ id: 'message.no-team-websites',
+ defaultMessage: 'This team does not have any websites.',
+ },
+ teamWebsitesInfo: {
+ id: 'message.team-websites-info',
+ defaultMessage: 'Websites can be viewed by anyone on the team.',
+ },
+ noMatchPassword: { id: 'message.no-match-password', defaultMessage: 'Passwords do not match.' },
+ goToSettings: {
+ id: 'message.go-to-settings',
+ defaultMessage: 'Go to settings',
+ },
+ activeUsers: {
+ id: 'message.active-users',
+ defaultMessage: '{x} current {x, plural, one {visitor} other {visitors}}',
+ },
+ teamNotFound: {
+ id: 'message.team-not-found',
+ defaultMessage: 'Team not found.',
+ },
+ visitorLog: {
+ id: 'message.visitor-log',
+ defaultMessage: 'Visitor from {country} using {browser} on {os} {device}',
+ },
+ eventLog: {
+ id: 'message.event-log',
+ defaultMessage: '{event} on {url}',
+ },
+ incorrectUsernamePassword: {
+ id: 'message.incorrect-username-password',
+ defaultMessage: 'Incorrect username and/or password.',
+ },
+ noEventData: {
+ id: 'message.no-event-data',
+ defaultMessage: 'No event data is available.',
+ },
+ newVersionAvailable: {
+ id: 'message.new-version-available',
+ defaultMessage: 'A new version of Umami {version} is available!',
+ },
+ transferWebsite: {
+ id: 'message.transfer-website',
+ defaultMessage: 'Transfer website ownership to your account or another team.',
+ },
+ transferTeamWebsiteToUser: {
+ id: 'message.transfer-team-website-to-user',
+ defaultMessage: 'Transfer this website to your account?',
+ },
+ transferUserWebsiteToTeam: {
+ id: 'message.transfer-user-website-to-team',
+ defaultMessage: 'Select the team to transfer this website to.',
+ },
+ unauthorized: {
+ id: 'message.unauthorized',
+ defaultMessage: 'Unauthorized',
+ },
+ badRequest: {
+ id: 'message.bad-request',
+ defaultMessage: 'Bad request',
+ },
+ forbidden: {
+ id: 'message.forbidden',
+ defaultMessage: 'Forbidden',
+ },
+ notFound: {
+ id: 'message.not-found',
+ defaultMessage: 'Not found',
+ },
+ serverError: {
+ id: 'message.sever-error',
+ defaultMessage: 'Server error',
+ },
+});
diff --git a/src/components/metrics/ActiveUsers.tsx b/src/components/metrics/ActiveUsers.tsx
new file mode 100644
index 0000000..a4bc7da
--- /dev/null
+++ b/src/components/metrics/ActiveUsers.tsx
@@ -0,0 +1,39 @@
+import { StatusLight, Text } from '@umami/react-zen';
+import { useMemo } from 'react';
+import { LinkButton } from '@/components/common/LinkButton';
+import { useActyiveUsersQuery, useMessages } from '@/components/hooks';
+
+export function ActiveUsers({
+ websiteId,
+ value,
+ refetchInterval = 60000,
+}: {
+ websiteId: string;
+ value?: number;
+ refetchInterval?: number;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { data } = useActyiveUsersQuery(websiteId, { refetchInterval });
+
+ const count = useMemo(() => {
+ if (websiteId) {
+ return data?.visitors || 0;
+ }
+
+ return value !== undefined ? value : 0;
+ }, [data, value, websiteId]);
+
+ if (count === 0) {
+ return null;
+ }
+
+ return (
+ <LinkButton href={`/websites/${websiteId}/realtime`} variant="quiet">
+ <StatusLight variant="success">
+ <Text size="2" weight="medium">
+ {count} {formatMessage(labels.online)}
+ </Text>
+ </StatusLight>
+ </LinkButton>
+ );
+}
diff --git a/src/components/metrics/ChangeLabel.tsx b/src/components/metrics/ChangeLabel.tsx
new file mode 100644
index 0000000..192f0ff
--- /dev/null
+++ b/src/components/metrics/ChangeLabel.tsx
@@ -0,0 +1,60 @@
+import { Icon, Row, type RowProps, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { ArrowRight } from '@/components/icons';
+
+const STYLES = {
+ positive: {
+ color: `var(--success-color)`,
+ background: `color-mix(in srgb, var(--success-color), var(--background-color) 95%)`,
+ },
+ negative: {
+ color: `var(--danger-color)`,
+ background: `color-mix(in srgb, var(--danger-color), var(--background-color) 95%)`,
+ },
+ neutral: {
+ color: `var(--font-color-muted)`,
+ background: `var(--base-color-2)`,
+ },
+};
+
+export function ChangeLabel({
+ value,
+ size,
+ reverseColors,
+ children,
+ ...props
+}: {
+ value: number;
+ size?: 'xs' | 'sm' | 'md' | 'lg';
+ title?: string;
+ reverseColors?: boolean;
+ showPercentage?: boolean;
+ children?: ReactNode;
+} & RowProps) {
+ const positive = value >= 0;
+ const negative = value < 0;
+ const neutral = value === 0 || Number.isNaN(value);
+ const good = reverseColors ? negative : positive;
+
+ const style =
+ STYLES[good && 'positive'] || STYLES[!good && 'negative'] || STYLES[neutral && 'neutral'];
+
+ return (
+ <Row
+ {...props}
+ style={style}
+ alignItems="center"
+ alignSelf="flex-start"
+ paddingX="2"
+ paddingY="1"
+ gap="2"
+ >
+ {!neutral && (
+ <Icon rotate={positive ? -90 : 90} size={size}>
+ <ArrowRight />
+ </Icon>
+ )}
+ <Text>{children || value}</Text>
+ </Row>
+ );
+}
diff --git a/src/components/metrics/DatePickerForm.tsx b/src/components/metrics/DatePickerForm.tsx
new file mode 100644
index 0000000..59d1709
--- /dev/null
+++ b/src/components/metrics/DatePickerForm.tsx
@@ -0,0 +1,74 @@
+import { Button, Calendar, Column, Row, ToggleGroup, ToggleGroupItem } from '@umami/react-zen';
+import { endOfDay, isAfter, isBefore, isSameDay, startOfDay } from 'date-fns';
+import { useState } from 'react';
+import { useMessages } from '@/components/hooks';
+
+const FILTER_DAY = 'filter-day';
+const FILTER_RANGE = 'filter-range';
+
+export function DatePickerForm({
+ startDate: defaultStartDate,
+ endDate: defaultEndDate,
+ minDate,
+ maxDate,
+ onChange,
+ onClose,
+}) {
+ const [selected, setSelected] = useState<any>([
+ isSameDay(defaultStartDate, defaultEndDate) ? FILTER_DAY : FILTER_RANGE,
+ ]);
+ const [date, setDate] = useState(defaultStartDate || new Date());
+ const [startDate, setStartDate] = useState(defaultStartDate || new Date());
+ const [endDate, setEndDate] = useState(defaultEndDate || new Date());
+ const { formatMessage, labels } = useMessages();
+
+ const disabled = selected.includes(FILTER_DAY)
+ ? isAfter(minDate, date) && isBefore(maxDate, date)
+ : isAfter(startDate, endDate);
+
+ const handleSave = () => {
+ if (selected.includes(FILTER_DAY)) {
+ onChange(`range:${startOfDay(date).getTime()}:${endOfDay(date).getTime()}`);
+ } else {
+ onChange(`range:${startOfDay(startDate).getTime()}:${endOfDay(endDate).getTime()}`);
+ }
+ };
+
+ return (
+ <Column gap>
+ <Row justifyContent="center">
+ <ToggleGroup disallowEmptySelection value={selected} onChange={setSelected}>
+ <ToggleGroupItem id={FILTER_DAY}>{formatMessage(labels.singleDay)}</ToggleGroupItem>
+ <ToggleGroupItem id={FILTER_RANGE}>{formatMessage(labels.dateRange)}</ToggleGroupItem>
+ </ToggleGroup>
+ </Row>
+ <Column>
+ {selected.includes(FILTER_DAY) && (
+ <Calendar value={date} minValue={minDate} maxValue={maxDate} onChange={setDate} />
+ )}
+ {selected.includes(FILTER_RANGE) && (
+ <Row gap wrap="wrap" style={{ margin: '0 auto' }}>
+ <Calendar
+ value={startDate}
+ minValue={minDate}
+ maxValue={endDate}
+ onChange={setStartDate}
+ />
+ <Calendar
+ value={endDate}
+ minValue={startDate}
+ maxValue={maxDate}
+ onChange={setEndDate}
+ />
+ </Row>
+ )}
+ </Column>
+ <Row justifyContent="end" gap>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <Button variant="primary" onPress={handleSave} isDisabled={disabled}>
+ {formatMessage(labels.apply)}
+ </Button>
+ </Row>
+ </Column>
+ );
+}
diff --git a/src/components/metrics/EventData.tsx b/src/components/metrics/EventData.tsx
new file mode 100644
index 0000000..48d21c5
--- /dev/null
+++ b/src/components/metrics/EventData.tsx
@@ -0,0 +1,22 @@
+import { Column, Grid, Label, Text } from '@umami/react-zen';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useEventDataQuery } from '@/components/hooks';
+
+export function EventData({ websiteId, eventId }: { websiteId: string; eventId: string }) {
+ const { data, isLoading, error } = useEventDataQuery(websiteId, eventId);
+
+ return (
+ <LoadingPanel isLoading={isLoading} error={error}>
+ <Grid columns="1fr 1fr" gap="5">
+ {data?.map(({ dataKey, stringValue }) => {
+ return (
+ <Column key={dataKey}>
+ <Label>{dataKey}</Label>
+ <Text>{stringValue}</Text>
+ </Column>
+ );
+ })}
+ </Grid>
+ </LoadingPanel>
+ );
+}
diff --git a/src/components/metrics/EventsChart.tsx b/src/components/metrics/EventsChart.tsx
new file mode 100644
index 0000000..3a53ba9
--- /dev/null
+++ b/src/components/metrics/EventsChart.tsx
@@ -0,0 +1,93 @@
+import { colord } from 'colord';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { BarChart, type BarChartProps } from '@/components/charts/BarChart';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import {
+ useDateRange,
+ useLocale,
+ useTimezone,
+ useWebsiteEventsSeriesQuery,
+} from '@/components/hooks';
+import { renderDateLabels } from '@/lib/charts';
+import { CHART_COLORS } from '@/lib/constants';
+import { generateTimeSeries } from '@/lib/date';
+
+export interface EventsChartProps extends BarChartProps {
+ websiteId: string;
+ focusLabel?: string;
+}
+
+export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
+ const { timezone } = useTimezone();
+ const {
+ dateRange: { startDate, endDate, unit },
+ } = useDateRange({ timezone: timezone });
+ const { locale, dateLocale } = useLocale();
+ const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId);
+ const [label, setLabel] = useState<string>(focusLabel);
+
+ const chartData: any = useMemo(() => {
+ if (!data) return;
+
+ const map = (data as any[]).reduce((obj, { x, t, y }) => {
+ if (!obj[x]) {
+ obj[x] = [];
+ }
+
+ obj[x].push({ x: t, y });
+
+ return obj;
+ }, {});
+
+ if (!map || Object.keys(map).length === 0) {
+ return {
+ datasets: [
+ {
+ data: generateTimeSeries([], startDate, endDate, unit, dateLocale),
+ lineTension: 0,
+ borderWidth: 1,
+ },
+ ],
+ };
+ } else {
+ return {
+ datasets: Object.keys(map).map((key, index) => {
+ const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
+ return {
+ label: key,
+ data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
+ lineTension: 0,
+ backgroundColor: color.alpha(0.6).toRgbString(),
+ borderColor: color.alpha(0.7).toRgbString(),
+ borderWidth: 1,
+ };
+ }),
+ focusLabel,
+ };
+ }
+ }, [data, startDate, endDate, unit, focusLabel]);
+
+ useEffect(() => {
+ if (label !== focusLabel) {
+ setLabel(focusLabel);
+ }
+ }, [focusLabel]);
+
+ const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
+
+ return (
+ <LoadingPanel isLoading={isLoading} error={error} minHeight="400px">
+ {chartData && (
+ <BarChart
+ chartData={chartData}
+ minDate={startDate}
+ maxDate={endDate}
+ unit={unit}
+ stacked={true}
+ renderXLabel={renderXLabel}
+ height="400px"
+ />
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/components/metrics/Legend.tsx b/src/components/metrics/Legend.tsx
new file mode 100644
index 0000000..34ddb5a
--- /dev/null
+++ b/src/components/metrics/Legend.tsx
@@ -0,0 +1,39 @@
+import { Row, StatusLight, Text } from '@umami/react-zen';
+import type { LegendItem } from 'chart.js/auto';
+import { colord } from 'colord';
+
+export function Legend({
+ items = [],
+ onClick,
+}: {
+ items: any[];
+ onClick: (index: LegendItem) => void;
+}) {
+ if (!items.find(({ text }) => text)) {
+ return null;
+ }
+
+ return (
+ <Row gap wrap="wrap" justifyContent="center">
+ {items.map(item => {
+ const { text, fillStyle, hidden } = item;
+ const color = colord(fillStyle);
+
+ return (
+ <Row key={text} onClick={() => onClick(item)}>
+ <StatusLight color={color.alpha(color.alpha() + 0.2).toHex()}>
+ <Text
+ size="2"
+ color={hidden ? 'disabled' : undefined}
+ truncate={true}
+ style={{ maxWidth: '300px' }}
+ >
+ {text}
+ </Text>
+ </StatusLight>
+ </Row>
+ );
+ })}
+ </Row>
+ );
+}
diff --git a/src/components/metrics/ListTable.tsx b/src/components/metrics/ListTable.tsx
new file mode 100644
index 0000000..f233bfe
--- /dev/null
+++ b/src/components/metrics/ListTable.tsx
@@ -0,0 +1,152 @@
+import { config, useSpring } from '@react-spring/web';
+import { Column, Grid, Row, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { FixedSizeList } from 'react-window';
+import { AnimatedDiv } from '@/components/common/AnimatedDiv';
+import { Empty } from '@/components/common/Empty';
+import { useMessages, useMobile } from '@/components/hooks';
+import { formatLongCurrency, formatLongNumber } from '@/lib/format';
+
+const ITEM_SIZE = 30;
+
+interface ListData {
+ label: string;
+ count: number;
+ percent: number;
+}
+
+export interface ListTableProps {
+ data?: ListData[];
+ title?: string;
+ metric?: string;
+ className?: string;
+ renderLabel?: (data: ListData, index: number) => ReactNode;
+ renderChange?: (data: ListData, index: number) => ReactNode;
+ animate?: boolean;
+ virtualize?: boolean;
+ showPercentage?: boolean;
+ itemCount?: number;
+ currency?: string;
+}
+
+export function ListTable({
+ data = [],
+ title,
+ metric,
+ renderLabel,
+ renderChange,
+ animate = true,
+ virtualize = false,
+ showPercentage = true,
+ itemCount = 10,
+ currency,
+}: ListTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { isPhone } = useMobile();
+
+ const getRow = (row: ListData, index: number) => {
+ const { label, count, percent } = row;
+
+ return (
+ <AnimatedRow
+ key={`${label}${index}`}
+ label={renderLabel ? renderLabel(row, index) : (label ?? formatMessage(labels.unknown))}
+ value={count}
+ percent={percent}
+ animate={animate && !virtualize}
+ showPercentage={showPercentage}
+ change={renderChange ? renderChange(row, index) : null}
+ currency={currency}
+ isPhone={isPhone}
+ />
+ );
+ };
+
+ const ListTableRow = ({ index, style }) => {
+ return <div style={style}>{getRow(data[index], index)}</div>;
+ };
+
+ return (
+ <Column gap>
+ <Grid alignItems="center" justifyContent="space-between" paddingLeft="2" columns="1fr 100px">
+ <Text weight="bold">{title}</Text>
+ <Text weight="bold" align="center">
+ {metric}
+ </Text>
+ </Grid>
+ <Column gap="1">
+ {data?.length === 0 && <Empty />}
+ {virtualize && data.length > 0 ? (
+ <FixedSizeList
+ width="100%"
+ height={itemCount * ITEM_SIZE}
+ itemCount={data.length}
+ itemSize={ITEM_SIZE}
+ >
+ {ListTableRow}
+ </FixedSizeList>
+ ) : (
+ data.map(getRow)
+ )}
+ </Column>
+ </Column>
+ );
+}
+
+const AnimatedRow = ({
+ label,
+ value = 0,
+ percent,
+ change,
+ animate,
+ showPercentage = true,
+ currency,
+ isPhone,
+}) => {
+ const props = useSpring({
+ width: percent,
+ y: !Number.isNaN(value) ? value : 0,
+ from: { width: 0, y: 0 },
+ config: animate ? config.default : { duration: 0 },
+ });
+
+ return (
+ <Grid
+ columns="1fr 50px 50px"
+ paddingLeft="2"
+ alignItems="center"
+ hoverBackgroundColor="2"
+ borderRadius
+ gap
+ >
+ <Row alignItems="center">
+ <Text truncate={true} style={{ maxWidth: isPhone ? '200px' : '400px' }}>
+ {label}
+ </Text>
+ </Row>
+ <Row alignItems="center" height="30px" justifyContent="flex-end">
+ {change}
+ <Text weight="bold">
+ <AnimatedDiv title={props?.y as any}>
+ {currency
+ ? props.y?.to(n => formatLongCurrency(n, currency))
+ : props.y?.to(formatLongNumber)}
+ </AnimatedDiv>
+ </Text>
+ </Row>
+ {showPercentage && (
+ <Row
+ alignItems="center"
+ justifyContent="flex-start"
+ position="relative"
+ border="left"
+ borderColor="8"
+ color="muted"
+ paddingLeft="3"
+ >
+ <AnimatedDiv>{props.width.to(n => `${n?.toFixed?.(0)}%`)}</AnimatedDiv>
+ </Row>
+ )}
+ </Grid>
+ );
+};
diff --git a/src/components/metrics/MetricCard.tsx b/src/components/metrics/MetricCard.tsx
new file mode 100644
index 0000000..d15bcf1
--- /dev/null
+++ b/src/components/metrics/MetricCard.tsx
@@ -0,0 +1,56 @@
+import { useSpring } from '@react-spring/web';
+import { Column, Text } from '@umami/react-zen';
+import { AnimatedDiv } from '@/components/common/AnimatedDiv';
+import { ChangeLabel } from '@/components/metrics/ChangeLabel';
+import { formatNumber } from '@/lib/format';
+
+export interface MetricCardProps {
+ value: number;
+ previousValue?: number;
+ change?: number;
+ label?: string;
+ reverseColors?: boolean;
+ formatValue?: (n: any) => string;
+ showLabel?: boolean;
+ showChange?: boolean;
+}
+
+export const MetricCard = ({
+ value = 0,
+ change = 0,
+ label,
+ reverseColors = false,
+ formatValue = formatNumber,
+ showLabel = true,
+ showChange = false,
+}: MetricCardProps) => {
+ const diff = value - change;
+ const pct = ((value - diff) / diff) * 100;
+ const props = useSpring({ x: Number(value) || 0, from: { x: 0 } });
+ const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } });
+
+ return (
+ <Column
+ justifyContent="center"
+ paddingX="6"
+ paddingY="4"
+ borderRadius="3"
+ backgroundColor
+ border
+ >
+ {showLabel && (
+ <Text weight="bold" wrap="nowrap">
+ {label}
+ </Text>
+ )}
+ <Text size="8" weight="bold" wrap="nowrap">
+ <AnimatedDiv title={value?.toString()}>{props?.x?.to(x => formatValue(x))}</AnimatedDiv>
+ </Text>
+ {showChange && (
+ <ChangeLabel value={change} title={formatValue(change)} reverseColors={reverseColors}>
+ <AnimatedDiv>{changeProps?.x?.to(x => `${Math.abs(~~x)}%`)}</AnimatedDiv>
+ </ChangeLabel>
+ )}
+ </Column>
+ );
+};
diff --git a/src/components/metrics/MetricLabel.tsx b/src/components/metrics/MetricLabel.tsx
new file mode 100644
index 0000000..31c331f
--- /dev/null
+++ b/src/components/metrics/MetricLabel.tsx
@@ -0,0 +1,142 @@
+import { Row } from '@umami/react-zen';
+import { Favicon } from '@/components/common/Favicon';
+import { FilterLink } from '@/components/common/FilterLink';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import {
+ useCountryNames,
+ useFormat,
+ useLocale,
+ useMessages,
+ useRegionNames,
+} from '@/components/hooks';
+import { GROUPED_DOMAINS } from '@/lib/constants';
+
+export interface MetricLabelProps {
+ type: string;
+ data: any;
+ onClick?: () => void;
+}
+
+export function MetricLabel({ type, data }: MetricLabelProps) {
+ const { formatMessage, labels } = useMessages();
+ const { formatValue, formatCity } = useFormat();
+ const { locale } = useLocale();
+ const { countryNames } = useCountryNames(locale);
+ const { getRegionName } = useRegionNames(locale);
+
+ const { label, country, domain } = data;
+
+ switch (type) {
+ case 'browser':
+ case 'os':
+ return (
+ <FilterLink
+ type={type}
+ value={label}
+ label={formatValue(label, type)}
+ icon={<TypeIcon type={type} value={label} />}
+ />
+ );
+
+ case 'channel':
+ return formatMessage(labels[label]);
+
+ case 'city':
+ return (
+ <FilterLink
+ type="city"
+ value={label}
+ label={formatCity(label, country)}
+ icon={
+ country && (
+ <img
+ src={`${process.env.basePath || ''}/images/country/${
+ country?.toLowerCase() || 'xx'
+ }.png`}
+ alt={country}
+ />
+ )
+ }
+ />
+ );
+
+ case 'region':
+ return (
+ <FilterLink
+ type="region"
+ value={label}
+ label={getRegionName(label, country)}
+ icon={<TypeIcon type="country" value={country} />}
+ />
+ );
+
+ case 'country':
+ return (
+ <FilterLink
+ type="country"
+ value={(countryNames[label] && label) || label}
+ label={formatValue(label, 'country')}
+ icon={<TypeIcon type="country" value={label} />}
+ />
+ );
+
+ case 'path':
+ case 'entry':
+ case 'exit':
+ return (
+ <FilterLink
+ type={type === 'entry' || type === 'exit' ? 'path' : type}
+ value={label}
+ label={!label && formatMessage(labels.none)}
+ externalUrl={
+ domain ? `${domain?.startsWith('http') ? domain : `https://${domain}`}${label}` : null
+ }
+ />
+ );
+
+ case 'device':
+ return (
+ <FilterLink
+ type="device"
+ value={labels[label] && label}
+ label={formatValue(label, 'device')}
+ icon={<TypeIcon type="device" value={label} />}
+ />
+ );
+
+ case 'referrer':
+ return (
+ <FilterLink
+ type="referrer"
+ value={label}
+ externalUrl={`https://${label}`}
+ label={!label && formatMessage(labels.none)}
+ icon={<Favicon domain={label} />}
+ />
+ );
+
+ case 'domain':
+ if (label === 'Other') {
+ return `(${formatMessage(labels.other)})`;
+ } else {
+ const name = GROUPED_DOMAINS.find(({ domain }) => domain === label)?.name;
+
+ if (!name) {
+ return null;
+ }
+
+ return (
+ <Row alignItems="center" gap="3">
+ <Favicon domain={label} />
+ {name}
+ </Row>
+ );
+ }
+
+ case 'language':
+ return formatValue(label, 'language');
+
+ default:
+ return <FilterLink type={type} value={label} />;
+ }
+}
diff --git a/src/components/metrics/MetricsBar.tsx b/src/components/metrics/MetricsBar.tsx
new file mode 100644
index 0000000..850c6bc
--- /dev/null
+++ b/src/components/metrics/MetricsBar.tsx
@@ -0,0 +1,14 @@
+import { Grid, type GridProps } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+
+export interface MetricsBarProps extends GridProps {
+ children?: ReactNode;
+}
+
+export function MetricsBar({ children, ...props }: MetricsBarProps) {
+ return (
+ <Grid columns="repeat(auto-fit, minmax(160px, 1fr))" gap {...props}>
+ {children}
+ </Grid>
+ );
+}
diff --git a/src/components/metrics/MetricsExpandedTable.tsx b/src/components/metrics/MetricsExpandedTable.tsx
new file mode 100644
index 0000000..f24c952
--- /dev/null
+++ b/src/components/metrics/MetricsExpandedTable.tsx
@@ -0,0 +1,139 @@
+import { Button, Column, DataColumn, DataTable, Icon, Row, SearchField } from '@umami/react-zen';
+import { type ReactNode, useState } from 'react';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useWebsiteExpandedMetricsQuery } from '@/components/hooks';
+import { X } from '@/components/icons';
+import { DownloadButton } from '@/components/input/DownloadButton';
+import { MetricLabel } from '@/components/metrics/MetricLabel';
+import { SESSION_COLUMNS } from '@/lib/constants';
+import { formatShortTime } from '@/lib/format';
+
+export interface MetricsExpandedTableProps {
+ websiteId: string;
+ type?: string;
+ title?: string;
+ dataFilter?: (data: any) => any;
+ onSearch?: (search: string) => void;
+ params?: { [key: string]: any };
+ allowSearch?: boolean;
+ allowDownload?: boolean;
+ renderLabel?: (row: any, index: number) => ReactNode;
+ onClose?: () => void;
+ children?: ReactNode;
+}
+
+export function MetricsExpandedTable({
+ websiteId,
+ type,
+ title,
+ params,
+ allowSearch = true,
+ allowDownload = true,
+ onClose,
+ children,
+}: MetricsExpandedTableProps) {
+ const [search, setSearch] = useState('');
+ const { formatMessage, labels } = useMessages();
+ const isType = ['browser', 'country', 'device', 'os'].includes(type);
+ const showBounceDuration = SESSION_COLUMNS.includes(type);
+
+ const { data, isLoading, isFetching, error } = useWebsiteExpandedMetricsQuery(websiteId, {
+ type,
+ search: isType ? undefined : search,
+ ...params,
+ });
+
+ const items = data?.map(({ name, ...props }) => ({ label: name, ...props }));
+
+ return (
+ <>
+ <Row alignItems="center" paddingBottom="3">
+ {allowSearch && <SearchField value={search} onSearch={setSearch} delay={300} />}
+ <Row justifyContent="flex-end" flexGrow={1} gap>
+ {children}
+ {allowDownload && <DownloadButton filename={type} data={data} />}
+ {onClose && (
+ <Button onPress={onClose} variant="quiet">
+ <Icon>
+ <X />
+ </Icon>
+ </Button>
+ )}
+ </Row>
+ </Row>
+ <LoadingPanel
+ data={data}
+ isFetching={isFetching}
+ isLoading={isLoading}
+ error={error}
+ height="100%"
+ loadingIcon="spinner"
+ >
+ <Column overflow="auto" minHeight="0" height="100%" paddingRight="3">
+ {items && (
+ <DataTable data={items}>
+ <DataColumn id="label" label={title} width="minmax(200px, 2fr)" align="start">
+ {row => (
+ <Row overflow="hidden">
+ <MetricLabel type={type} data={row} />
+ </Row>
+ )}
+ </DataColumn>
+ <DataColumn
+ id="visitors"
+ label={formatMessage(labels.visitors)}
+ align="end"
+ width="120px"
+ >
+ {row => row?.visitors?.toLocaleString()}
+ </DataColumn>
+ <DataColumn
+ id="visits"
+ label={formatMessage(labels.visits)}
+ align="end"
+ width="120px"
+ >
+ {row => row?.visits?.toLocaleString()}
+ </DataColumn>
+ <DataColumn
+ id="pageviews"
+ label={formatMessage(labels.views)}
+ align="end"
+ width="120px"
+ >
+ {row => row?.pageviews?.toLocaleString()}
+ </DataColumn>
+ {showBounceDuration && [
+ <DataColumn
+ key="bounceRate"
+ id="bounceRate"
+ label={formatMessage(labels.bounceRate)}
+ align="end"
+ width="120px"
+ >
+ {row => {
+ const n = (Math.min(row?.visits, row?.bounces) / row?.visits) * 100;
+ return `${Math.round(+n)}%`;
+ }}
+ </DataColumn>,
+
+ <DataColumn
+ key="visitDuration"
+ id="visitDuration"
+ label={formatMessage(labels.visitDuration)}
+ align="end"
+ width="120px"
+ >
+ {row => {
+ const n = row?.totaltime / row?.visits;
+ return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
+ }}
+ </DataColumn>,
+ ]}
+ </DataTable>
+ )}
+ </Column>
+ </LoadingPanel>
+ </>
+ );
+}
diff --git a/src/components/metrics/MetricsTable.tsx b/src/components/metrics/MetricsTable.tsx
new file mode 100644
index 0000000..e99bd21
--- /dev/null
+++ b/src/components/metrics/MetricsTable.tsx
@@ -0,0 +1,95 @@
+import { Grid, Icon, Row, Text } from '@umami/react-zen';
+import { useEffect, useMemo } from 'react';
+import { LinkButton } from '@/components/common/LinkButton';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useNavigation, useWebsiteMetricsQuery } from '@/components/hooks';
+import { Maximize } from '@/components/icons';
+import { MetricLabel } from '@/components/metrics/MetricLabel';
+import { percentFilter } from '@/lib/filters';
+import { ListTable, type ListTableProps } from './ListTable';
+
+export interface MetricsTableProps extends ListTableProps {
+ websiteId: string;
+ type: string;
+ dataFilter?: (data: any) => any;
+ limit?: number;
+ showMore?: boolean;
+ filterLink?: boolean;
+ params?: Record<string, any>;
+ onDataLoad?: (data: any) => void;
+}
+
+export function MetricsTable({
+ websiteId,
+ type,
+ dataFilter,
+ limit,
+ showMore = false,
+ filterLink = true,
+ params,
+ onDataLoad,
+ ...props
+}: MetricsTableProps) {
+ const { updateParams } = useNavigation();
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useWebsiteMetricsQuery(websiteId, {
+ type,
+ limit,
+ ...params,
+ });
+
+ const filteredData = useMemo(() => {
+ if (data) {
+ let items = data as any[];
+
+ if (dataFilter) {
+ if (Array.isArray(dataFilter)) {
+ items = dataFilter.reduce((arr, filter) => {
+ return filter(arr);
+ }, items);
+ } else {
+ items = dataFilter(items);
+ }
+ }
+
+ items = percentFilter(items);
+
+ return items.map(({ x, y, z, ...props }) => ({ label: x, count: y, percent: z, ...props }));
+ }
+ return [];
+ }, [data, dataFilter, limit, type]);
+
+ useEffect(() => {
+ if (data) {
+ onDataLoad?.(data);
+ }
+ }, [data]);
+
+ const renderLabel = (row: any) => {
+ return filterLink ? <MetricLabel type={type} data={row} /> : row.label;
+ };
+
+ return (
+ <LoadingPanel
+ data={data}
+ isFetching={isFetching}
+ isLoading={isLoading}
+ error={error}
+ minHeight="400px"
+ >
+ <Grid>
+ {data && <ListTable {...props} data={filteredData} renderLabel={renderLabel} />}
+ {showMore && limit && (
+ <Row justifyContent="center" alignItems="flex-end">
+ <LinkButton href={updateParams({ view: type })} variant="quiet">
+ <Icon size="sm">
+ <Maximize />
+ </Icon>
+ <Text>{formatMessage(labels.more)}</Text>
+ </LinkButton>
+ </Row>
+ )}
+ </Grid>
+ </LoadingPanel>
+ );
+}
diff --git a/src/components/metrics/PageviewsChart.tsx b/src/components/metrics/PageviewsChart.tsx
new file mode 100644
index 0000000..b83f8dc
--- /dev/null
+++ b/src/components/metrics/PageviewsChart.tsx
@@ -0,0 +1,98 @@
+import { useTheme } from '@umami/react-zen';
+import { useCallback, useMemo } from 'react';
+import { BarChart, type BarChartProps } from '@/components/charts/BarChart';
+import { useLocale, useMessages } from '@/components/hooks';
+import { renderDateLabels } from '@/lib/charts';
+import { getThemeColors } from '@/lib/colors';
+import { generateTimeSeries } from '@/lib/date';
+
+export interface PageviewsChartProps extends BarChartProps {
+ data: {
+ pageviews: any[];
+ sessions: any[];
+ compare?: {
+ pageviews: any[];
+ sessions: any[];
+ };
+ };
+ unit: string;
+}
+
+export function PageviewsChart({ data, unit, minDate, maxDate, ...props }: PageviewsChartProps) {
+ const { formatMessage, labels } = useMessages();
+ const { theme } = useTheme();
+ const { locale, dateLocale } = useLocale();
+ const { colors } = useMemo(() => getThemeColors(theme), [theme]);
+
+ const chartData: any = useMemo(() => {
+ if (!data) return;
+
+ return {
+ __id: Date.now(),
+ datasets: [
+ {
+ type: 'bar',
+ label: formatMessage(labels.visitors),
+ data: generateTimeSeries(data.sessions, minDate, maxDate, unit, dateLocale),
+ borderWidth: 1,
+ barPercentage: 0.9,
+ categoryPercentage: 0.9,
+ ...colors.chart.visitors,
+ order: 3,
+ },
+ {
+ type: 'bar',
+ label: formatMessage(labels.views),
+ data: generateTimeSeries(data.pageviews, minDate, maxDate, unit, dateLocale),
+ barPercentage: 0.9,
+ categoryPercentage: 0.9,
+ borderWidth: 1,
+ ...colors.chart.views,
+ order: 4,
+ },
+ ...(data.compare
+ ? [
+ {
+ type: 'line',
+ label: `${formatMessage(labels.views)} (${formatMessage(labels.previous)})`,
+ data: generateTimeSeries(
+ data.compare.pageviews,
+ minDate,
+ maxDate,
+ unit,
+ dateLocale,
+ ),
+ borderWidth: 2,
+ backgroundColor: '#8601B0',
+ borderColor: '#8601B0',
+ order: 1,
+ },
+ {
+ type: 'line',
+ label: `${formatMessage(labels.visitors)} (${formatMessage(labels.previous)})`,
+ data: generateTimeSeries(data.compare.sessions, minDate, maxDate, unit, dateLocale),
+ borderWidth: 2,
+ backgroundColor: '#f15bb5',
+ borderColor: '#f15bb5',
+ order: 2,
+ },
+ ]
+ : []),
+ ],
+ };
+ }, [data, locale]);
+
+ const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
+
+ return (
+ <BarChart
+ {...props}
+ chartData={chartData}
+ unit={unit}
+ minDate={minDate}
+ maxDate={maxDate}
+ renderXLabel={renderXLabel}
+ height="400px"
+ />
+ );
+}
diff --git a/src/components/metrics/RealtimeChart.tsx b/src/components/metrics/RealtimeChart.tsx
new file mode 100644
index 0000000..f42b96d
--- /dev/null
+++ b/src/components/metrics/RealtimeChart.tsx
@@ -0,0 +1,59 @@
+import { isBefore, startOfMinute, subMinutes } from 'date-fns';
+import { useMemo, useRef } from 'react';
+import { useTimezone } from '@/components/hooks';
+import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from '@/lib/constants';
+import type { RealtimeData } from '@/lib/types';
+import { PageviewsChart } from './PageviewsChart';
+
+export interface RealtimeChartProps {
+ data: RealtimeData;
+ unit: string;
+ className?: string;
+}
+
+export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
+ const { formatSeriesTimezone, fromUtc, timezone } = useTimezone();
+ const endDate = startOfMinute(new Date());
+ const startDate = subMinutes(endDate, REALTIME_RANGE);
+ const prevEndDate = useRef(endDate);
+ const prevData = useRef<string | null>(null);
+
+ const chartData = useMemo(() => {
+ if (!data) {
+ return { pageviews: [], sessions: [] };
+ }
+
+ return {
+ pageviews: formatSeriesTimezone(data.series.views, 'x', timezone),
+ sessions: formatSeriesTimezone(data.series.visitors, 'x', timezone),
+ };
+ }, [data, startDate, endDate, unit]);
+
+ const animationDuration = useMemo(() => {
+ // Don't animate the bars shifting over because it looks weird
+ if (isBefore(prevEndDate.current, endDate)) {
+ prevEndDate.current = endDate;
+ return 0;
+ }
+
+ // Don't animate when data hasn't changed
+ const serialized = JSON.stringify(chartData);
+ if (prevData.current === serialized) {
+ return 0;
+ }
+ prevData.current = serialized;
+
+ return DEFAULT_ANIMATION_DURATION;
+ }, [endDate, chartData]);
+
+ return (
+ <PageviewsChart
+ {...props}
+ minDate={fromUtc(startDate)}
+ maxDate={fromUtc(endDate)}
+ unit={unit}
+ data={chartData}
+ animationDuration={animationDuration}
+ />
+ );
+}
diff --git a/src/components/metrics/WeeklyTraffic.tsx b/src/components/metrics/WeeklyTraffic.tsx
new file mode 100644
index 0000000..90e47c6
--- /dev/null
+++ b/src/components/metrics/WeeklyTraffic.tsx
@@ -0,0 +1,112 @@
+import { Focusable, Grid, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import { addHours, format, startOfDay } from 'date-fns';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useLocale, useMessages, useWeeklyTrafficQuery } from '@/components/hooks';
+import { getDayOfWeekAsDate } from '@/lib/date';
+
+export function WeeklyTraffic({ websiteId }: { websiteId: string }) {
+ const { data, isLoading, error } = useWeeklyTrafficQuery(websiteId);
+ const { dateLocale } = useLocale();
+ const { labels, formatMessage } = useMessages();
+ const { weekStartsOn } = dateLocale.options;
+ const daysOfWeek = Array(7)
+ .fill(weekStartsOn)
+ .map((d, i) => (d + i) % 7);
+
+ const [, max = 1] = data
+ ? data.reduce((arr: number[], hours: number[], index: number) => {
+ const min = Math.min(...hours);
+ const max = Math.max(...hours);
+
+ if (index === 0) {
+ return [min, max];
+ }
+
+ if (min < arr[0]) {
+ arr[0] = min;
+ }
+
+ if (max > arr[1]) {
+ arr[1] = max;
+ }
+
+ return arr;
+ }, [])
+ : [];
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ <Grid columns="repeat(8, 1fr)" gap>
+ {data && (
+ <>
+ <Grid rows="repeat(25, 16px)" gap="1">
+ <Row>&nbsp;</Row>
+ {Array(24)
+ .fill(null)
+ .map((_, i) => {
+ const label = format(addHours(startOfDay(new Date()), i), 'haaa', {
+ locale: dateLocale,
+ });
+ return (
+ <Row key={i} justifyContent="flex-end">
+ <Text color="muted" size="2">
+ {label}
+ </Text>
+ </Row>
+ );
+ })}
+ </Grid>
+ {daysOfWeek.map((index: number) => {
+ const day = data[index];
+ return (
+ <Grid
+ rows="repeat(24, 16px)"
+ justifyContent="center"
+ alignItems="center"
+ key={index}
+ gap="1"
+ >
+ <Row alignItems="center" justifyContent="center" marginBottom="3">
+ <Text weight="bold" align="center">
+ {format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })}
+ </Text>
+ </Row>
+ {day?.map((count: number, j) => {
+ const pct = max ? count / max : 0;
+ return (
+ <TooltipTrigger key={j} delay={0} isDisabled={count <= 0}>
+ <Focusable>
+ <Row
+ alignItems="center"
+ justifyContent="center"
+ backgroundColor="2"
+ width="16px"
+ height="16px"
+ borderRadius="full"
+ style={{ margin: '0 auto' }}
+ role="button"
+ >
+ <Row
+ backgroundColor="primary"
+ width="16px"
+ height="16px"
+ borderRadius="full"
+ style={{ opacity: pct, transform: `scale(${pct})` }}
+ />
+ </Row>
+ </Focusable>
+ <Tooltip placement="right">{`${formatMessage(
+ labels.visitors,
+ )}: ${count}`}</Tooltip>
+ </TooltipTrigger>
+ );
+ })}
+ </Grid>
+ );
+ })}
+ </>
+ )}
+ </Grid>
+ </LoadingPanel>
+ );
+}
diff --git a/src/components/metrics/WorldMap.tsx b/src/components/metrics/WorldMap.tsx
new file mode 100644
index 0000000..3c8fadb
--- /dev/null
+++ b/src/components/metrics/WorldMap.tsx
@@ -0,0 +1,105 @@
+import { Column, type ColumnProps, FloatingTooltip, useTheme } from '@umami/react-zen';
+import { colord } from 'colord';
+import { useMemo, useState } from 'react';
+import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
+import {
+ useCountryNames,
+ useLocale,
+ useMessages,
+ useWebsiteMetricsQuery,
+} from '@/components/hooks';
+import { getThemeColors } from '@/lib/colors';
+import { ISO_COUNTRIES, MAP_FILE } from '@/lib/constants';
+import { percentFilter } from '@/lib/filters';
+import { formatLongNumber } from '@/lib/format';
+
+export interface WorldMapProps extends ColumnProps {
+ websiteId?: string;
+ data?: any[];
+}
+
+export function WorldMap({ websiteId, data, ...props }: WorldMapProps) {
+ const [tooltip, setTooltipPopup] = useState();
+ const { theme } = useTheme();
+ const { colors } = getThemeColors(theme);
+ const { locale } = useLocale();
+ const { formatMessage, labels } = useMessages();
+ const { countryNames } = useCountryNames(locale);
+ const visitorsLabel = formatMessage(labels.visitors).toLocaleLowerCase(locale);
+ const unknownLabel = formatMessage(labels.unknown);
+
+ const { data: mapData } = useWebsiteMetricsQuery(websiteId, {
+ type: 'country',
+ });
+
+ const metrics = useMemo(
+ () => (data || mapData ? percentFilter((data || mapData) as any[]) : []),
+ [data, mapData],
+ );
+
+ const getFillColor = (code: string) => {
+ if (code === 'AQ') return;
+ const country = metrics?.find(({ x }) => x === code);
+
+ if (!country) {
+ return colors.map.fillColor;
+ }
+
+ return colord(colors.map.baseColor)
+ [theme === 'light' ? 'lighten' : 'darken'](0.4 * (1.0 - country.z / 100))
+ .toHex();
+ };
+
+ const getOpacity = (code: string) => {
+ return code === 'AQ' ? 0 : 1;
+ };
+
+ const handleHover = (code: string) => {
+ if (code === 'AQ') return;
+ const country = metrics?.find(({ x }) => x === code);
+ setTooltipPopup(
+ `${countryNames[code] || unknownLabel}: ${formatLongNumber(
+ country?.y || 0,
+ )} ${visitorsLabel}` as any,
+ );
+ };
+
+ return (
+ <Column
+ {...props}
+ data-tip=""
+ data-for="world-map-tooltip"
+ style={{ margin: 'auto 0', overflow: 'hidden' }}
+ >
+ <ComposableMap projection="geoMercator">
+ <ZoomableGroup zoom={0.8} minZoom={0.7} center={[0, 40]}>
+ <Geographies geography={`${process.env.basePath || ''}${MAP_FILE}`}>
+ {({ geographies }) => {
+ return geographies.map(geo => {
+ const code = ISO_COUNTRIES[geo.id];
+
+ return (
+ <Geography
+ key={geo.rsmKey}
+ geography={geo}
+ fill={getFillColor(code)}
+ stroke={colors.map.strokeColor}
+ opacity={getOpacity(code)}
+ style={{
+ default: { outline: 'none' },
+ hover: { outline: 'none', fill: colors.map.hoverColor },
+ pressed: { outline: 'none' },
+ }}
+ onMouseOver={() => handleHover(code)}
+ onMouseOut={() => setTooltipPopup(null)}
+ />
+ );
+ });
+ }}
+ </Geographies>
+ </ZoomableGroup>
+ </ComposableMap>
+ {tooltip && <FloatingTooltip>{tooltip}</FloatingTooltip>}
+ </Column>
+ );
+}
diff --git a/src/components/svg/AddUser.tsx b/src/components/svg/AddUser.tsx
new file mode 100644
index 0000000..d1eb509
--- /dev/null
+++ b/src/components/svg/AddUser.tsx
@@ -0,0 +1,16 @@
+import type { SVGProps } from 'react';
+
+const SvgAddUser = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ data-name="Layer 2"
+ viewBox="0 0 30 30"
+ {...props}
+ >
+ <path d="M15 14a5.5 5.5 0 1 1 5.5-5.5A5.51 5.51 0 0 1 15 14m0-9a3.5 3.5 0 1 0 3.5 3.5A3.5 3.5 0 0 0 15 5M7.5 24.5a1 1 0 0 1-1-1 8.5 8.5 0 0 1 13.6-6.8 1 1 0 1 1-1.2 1.6A6.44 6.44 0 0 0 15 17a6.51 6.51 0 0 0-6.5 6.5 1 1 0 0 1-1 1M23 27a1 1 0 0 1-1-1v-6a1 1 0 0 1 2 0v6a1 1 0 0 1-1 1" />
+ <path d="M26 24h-6a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2" />
+ </svg>
+);
+export default SvgAddUser;
diff --git a/src/components/svg/BarChart.tsx b/src/components/svg/BarChart.tsx
new file mode 100644
index 0000000..96ebe00
--- /dev/null
+++ b/src/components/svg/BarChart.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgBarChart = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}>
+ <path d="M7 13v9a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1m7-12h-4a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1m8 5h-4a1 1 0 0 0-1 1v15a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1" />
+ </svg>
+);
+export default SvgBarChart;
diff --git a/src/components/svg/Bars.tsx b/src/components/svg/Bars.tsx
new file mode 100644
index 0000000..1ce88f7
--- /dev/null
+++ b/src/components/svg/Bars.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgBars = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" {...props}>
+ <path d="M424 392H24c-13.2 0-24 10.8-24 24s10.8 24 24 24h400c13.2 0 24-10.8 24-24s-10.8-24-24-24m0-320H24C10.8 72 0 82.8 0 96s10.8 24 24 24h400c13.2 0 24-10.8 24-24s-10.8-24-24-24m0 160H24c-13.2 0-24 10.8-24 24s10.8 24 24 24h400c13.2 0 24-10.8 24-24s-10.8-24-24-24" />
+ </svg>
+);
+export default SvgBars;
diff --git a/src/components/svg/Bolt.tsx b/src/components/svg/Bolt.tsx
new file mode 100644
index 0000000..23b1e76
--- /dev/null
+++ b/src/components/svg/Bolt.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgBolt = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" {...props}>
+ <path d="M296 160H180.6l42.6-129.8C227.2 15 215.7 0 200 0H56C44 0 33.8 8.9 32.2 20.8l-32 240C-1.7 275.2 9.5 288 24 288h118.7L96.6 482.5c-3.6 15.2 8 29.5 23.3 29.5 8.4 0 16.4-4.4 20.8-12l176-304c9.3-15.9-2.2-36-20.7-36" />
+ </svg>
+);
+export default SvgBolt;
diff --git a/src/components/svg/Bookmark.tsx b/src/components/svg/Bookmark.tsx
new file mode 100644
index 0000000..089f61f
--- /dev/null
+++ b/src/components/svg/Bookmark.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgBookmark = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}>
+ <path d="M3.515 22.875a1 1 0 0 0 1.015-.027L12 18.179l7.47 4.669A1 1 0 0 0 21 22V4a3 3 0 0 0-3-3H6a3 3 0 0 0-3 3v18a1 1 0 0 0 .515.875M5 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v16.2l-6.47-4.044a1 1 0 0 0-1.06 0L5 20.2z" />
+ </svg>
+);
+export default SvgBookmark;
diff --git a/src/components/svg/Calendar.tsx b/src/components/svg/Calendar.tsx
new file mode 100644
index 0000000..dfb848a
--- /dev/null
+++ b/src/components/svg/Calendar.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgCalendar = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" {...props}>
+ <path d="M400 64h-48V12c0-6.6-5.4-12-12-12h-8c-6.6 0-12 5.4-12 12v52H128V12c0-6.6-5.4-12-12-12h-8c-6.6 0-12 5.4-12 12v52H48C21.5 64 0 85.5 0 112v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48M48 96h352c8.8 0 16 7.2 16 16v48H32v-48c0-8.8 7.2-16 16-16m352 384H48c-8.8 0-16-7.2-16-16V192h384v272c0 8.8-7.2 16-16 16M148 320h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12m96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12m96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12m-96 96h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12m-96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12m192 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12" />
+ </svg>
+);
+export default SvgCalendar;
diff --git a/src/components/svg/Change.tsx b/src/components/svg/Change.tsx
new file mode 100644
index 0000000..935a2f7
--- /dev/null
+++ b/src/components/svg/Change.tsx
@@ -0,0 +1,13 @@
+import type { SVGProps } from 'react';
+
+const SvgChange = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ xmlSpace="preserve"
+ viewBox="0 0 512.013 512.013"
+ {...props}
+ >
+ <path d="m372.653 244.726 22.56 22.56 112-112c6.204-6.241 6.204-16.319 0-22.56l-112-112-22.56 22.72 84.8 84.64H.013v32h457.44zm139.36 107.36H54.573l84.8-84.64-22.72-22.72-112 112c-6.204 6.241-6.204 16.319 0 22.56l112 112 22.56-22.56-84.64-84.64h457.44z" />
+ </svg>
+);
+export default SvgChange;
diff --git a/src/components/svg/Clock.tsx b/src/components/svg/Clock.tsx
new file mode 100644
index 0000000..2dfa6a6
--- /dev/null
+++ b/src/components/svg/Clock.tsx
@@ -0,0 +1,12 @@
+import type { SVGProps } from 'react';
+
+const SvgClock = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}>
+ <g clipRule="evenodd">
+ <path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12" />
+ <path d="M11.168 11.445a1 1 0 0 1 1.387-.277l3 2a1 1 0 0 1-1.11 1.664l-3-2a1 1 0 0 1-.277-1.387" />
+ <path d="M12 6a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V7a1 1 0 0 1 1-1" />
+ </g>
+ </svg>
+);
+export default SvgClock;
diff --git a/src/components/svg/Compare.tsx b/src/components/svg/Compare.tsx
new file mode 100644
index 0000000..3434461
--- /dev/null
+++ b/src/components/svg/Compare.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgCompare = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}>
+ <path d="M6 22a1 1 0 0 1-.71-.29l-4-4a1 1 0 0 1 0-1.42l4-4a1 1 0 0 1 1.42 1.42L4.41 16H22a1 1 0 0 1 0 2H4.41l2.3 2.29a1 1 0 0 1 0 1.42A1 1 0 0 1 6 22m12-10a1 1 0 0 1-.71-.29 1 1 0 0 1 0-1.42L19.59 8H2a1 1 0 0 1 0-2h17.59l-2.3-2.29a1 1 0 0 1 1.42-1.42l4 4a1 1 0 0 1 0 1.42l-4 4A1 1 0 0 1 18 12" />
+ </svg>
+);
+export default SvgCompare;
diff --git a/src/components/svg/Dashboard.tsx b/src/components/svg/Dashboard.tsx
new file mode 100644
index 0000000..5696244
--- /dev/null
+++ b/src/components/svg/Dashboard.tsx
@@ -0,0 +1,21 @@
+import type { SVGProps } from 'react';
+
+const SvgDashboard = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ stroke="currentColor"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ className="dashboard_svg__lucide dashboard_svg__lucide-layout-dashboard"
+ viewBox="0 0 24 24"
+ {...props}
+ >
+ <rect width={7} height={9} x={3} y={3} rx={1} />
+ <rect width={7} height={5} x={14} y={3} rx={1} />
+ <rect width={7} height={9} x={14} y={12} rx={1} />
+ <rect width={7} height={5} x={3} y={16} rx={1} />
+ </svg>
+);
+export default SvgDashboard;
diff --git a/src/components/svg/Download.tsx b/src/components/svg/Download.tsx
new file mode 100644
index 0000000..5f58724
--- /dev/null
+++ b/src/components/svg/Download.tsx
@@ -0,0 +1,9 @@
+import type { SVGProps } from 'react';
+
+const SvgDownload = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" {...props}>
+ <path d="M97.5 82.656V71.357a3.545 3.545 0 0 0-3.545-3.544H89.17a3.545 3.545 0 0 0-3.545 3.544v11.3c0 1.639-1.33 2.968-2.969 2.968H17.344a2.97 2.97 0 0 1-2.969-2.969V71.357a3.545 3.545 0 0 0-3.545-3.545H6.045A3.545 3.545 0 0 0 2.5 71.357v11.3C2.5 90.853 9.146 97.5 17.344 97.5h65.312c8.198 0 14.844-6.646 14.844-14.844" />
+ <path d="m29.68 44.105-3.387 3.388a3.545 3.545 0 0 0 0 5.014l19.506 19.506a5.94 5.94 0 0 0 8.397.005l.005-.005 19.506-19.506a3.545 3.545 0 0 0 0-5.014l-3.388-3.388a3.545 3.545 0 0 0-5.013 0l-9.368 9.368V6.045A3.545 3.545 0 0 0 52.393 2.5h-4.786a3.545 3.545 0 0 0-3.544 3.545v47.428l-9.369-9.368a3.545 3.545 0 0 0-5.013 0" />
+ </svg>
+);
+export default SvgDownload;
diff --git a/src/components/svg/Expand.tsx b/src/components/svg/Expand.tsx
new file mode 100644
index 0000000..a0f472e
--- /dev/null
+++ b/src/components/svg/Expand.tsx
@@ -0,0 +1,18 @@
+import type { SVGProps } from 'react';
+
+const SvgExpand = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ fillRule="evenodd"
+ strokeLinejoin="round"
+ strokeMiterlimit={2}
+ clipRule="evenodd"
+ viewBox="0 0 48 48"
+ {...props}
+ >
+ <path d="M7.5 40.018v-10.5c0-1.379-1.12-2.5-2.5-2.5s-2.5 1.121-2.5 2.5v11a4.5 4.5 0 0 0 4.5 4.5h12a2.5 2.5 0 0 0 0-5zm33 0H29a2.5 2.5 0 0 0 0 5h12a4.5 4.5 0 0 0 4.5-4.5v-11c0-1.379-1.12-2.5-2.5-2.5s-2.5 1.121-2.5 2.5zm-33-33H19a2.5 2.5 0 0 0 0-5H7a4.5 4.5 0 0 0-4.5 4.5v11a2.5 2.5 0 0 0 5 0zm33 0v10.5a2.5 2.5 0 0 0 5 0v-11a4.5 4.5 0 0 0-4.5-4.5H29a2.5 2.5 0 0 0 0 5z" />
+ </svg>
+);
+export default SvgExpand;
diff --git a/src/components/svg/Export.tsx b/src/components/svg/Export.tsx
new file mode 100644
index 0000000..5c1ef14
--- /dev/null
+++ b/src/components/svg/Export.tsx
@@ -0,0 +1,12 @@
+import type { SVGProps } from 'react';
+
+const SvgExport = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}>
+ <switch>
+ <g>
+ <path d="M8.7 7.7 11 5.4V15c0 .6.4 1 1 1s1-.4 1-1V5.4l2.3 2.3c.4.4 1 .4 1.4 0s.4-1 0-1.4l-4-4c-.1-.1-.2-.2-.3-.2-.2-.1-.5-.1-.8 0-.1 0-.2.1-.3.2l-4 4c-.4.4-.4 1 0 1.4s1 .4 1.4 0M21 14c-.6 0-1 .4-1 1v4c0 .6-.4 1-1 1H5c-.6 0-1-.4-1-1v-4c0-.6-.4-1-1-1s-1 .4-1 1v4c0 1.7 1.3 3 3 3h14c1.7 0 3-1.3 3-3v-4c0-.6-.4-1-1-1" />
+ </g>
+ </switch>
+ </svg>
+);
+export default SvgExport;
diff --git a/src/components/svg/Flag.tsx b/src/components/svg/Flag.tsx
new file mode 100644
index 0000000..34af943
--- /dev/null
+++ b/src/components/svg/Flag.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgFlag = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 510 510" {...props}>
+ <path d="m393.159 121.41 69.152-86.44c-16.753-2.022-149.599-37.363-282.234-8.913V0h-30v361.898c-25.85 6.678-45 30.195-45 58.102v1.509c-34.191 6.969-60 37.272-60 73.491v15h240v-15c0-36.22-25.809-66.522-60-73.491V420c0-27.906-19.15-51.424-45-58.102V237.165c153.335-30.989 264.132 7.082 284.847 9.834zM252.506 480H77.647c6.19-17.461 22.873-30 42.43-30h90c19.556 0 36.238 12.539 42.429 30m-57.429-60h-60c0-16.542 13.458-30 30-30s30 13.458 30 30m-15-213.427V56.771c66.329-15.269 141.099-15.756 227.537-1.455l-50.619 63.274 48.8 85.4c-75.047-12.702-150.759-11.841-225.718 2.583" />
+ </svg>
+);
+export default SvgFlag;
diff --git a/src/components/svg/Funnel.tsx b/src/components/svg/Funnel.tsx
new file mode 100644
index 0000000..63cf47d
--- /dev/null
+++ b/src/components/svg/Funnel.tsx
@@ -0,0 +1,18 @@
+import type { SVGProps } from 'react';
+
+const SvgFunnel = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ fill="currentColor"
+ viewBox="0 0 32 32"
+ {...props}
+ >
+ <path d="M29 11H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h26a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1M4 9h24V5H4z" />
+ <path d="M25 17H7a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1M8 15h16v-4H8z" />
+ <path d="M22 23H10a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1m-11-2h10v-4H11z" />
+ <path d="M19 29h-6a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1m-5-2h4v-4h-4z" />
+ </svg>
+);
+export default SvgFunnel;
diff --git a/src/components/svg/Gear.tsx b/src/components/svg/Gear.tsx
new file mode 100644
index 0000000..539b838
--- /dev/null
+++ b/src/components/svg/Gear.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgGear = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path d="M504.265 315.978c0-8.652-4.607-16.844-12.359-21.392l-32.908-18.971a199 199 0 0 0 0-39.23l32.908-18.971c7.752-4.548 12.359-12.74 12.359-21.392 0-21.267-49.318-128.176-84.519-128.176-4.244 0-8.51 1.093-12.367 3.357l-32.78 18.969a195 195 0 0 0-34.068-19.744v-37.94c0-11.226-7.484-21.035-18.326-23.875C300.654 2.871 278.425 0 256.181 0a257.7 257.7 0 0 0-66.121 8.613c-10.842 2.84-18.326 12.649-18.326 23.875v37.94a195 195 0 0 0-34.068 19.744l-32.78-18.969a24.36 24.36 0 0 0-12.367-3.357h-.007C60.048 67.846 8 169.591 8 196.022c0 8.652 4.607 16.844 12.359 21.392l32.908 18.971a199 199 0 0 0 0 39.23l-32.908 18.971C12.607 299.134 8 307.326 8 315.978c0 21.267 49.318 128.176 84.519 128.176 4.244 0 8.51-1.093 12.367-3.357l32.78-18.969a195 195 0 0 0 34.068 19.744v37.94c0 11.226 7.484 21.035 18.326 23.875 21.551 5.742 43.78 8.613 66.024 8.613 22.246 0 44.506-2.871 66.121-8.613 10.842-2.84 18.326-12.649 18.326-23.875v-37.94a195 195 0 0 0 34.068-19.744l32.78 18.969a24.36 24.36 0 0 0 12.367 3.357c32.463 0 84.519-101.731 84.519-128.176m-88.904 73.981c-23.8-13.773-11.26-6.515-43.656-25.264-42.056 30.395-32.33 24.731-79.174 45.887v50.238a210 210 0 0 1-36.438 3.18 209 209 0 0 1-36.359-3.176v-50.242c-46.955-21.206-37.182-15.538-79.174-45.887l-43.636 25.254a207.4 207.4 0 0 1-36.407-63.109c21.126-12.177 11.844-6.826 43.571-25.117-2.539-25.64-3.811-35.644-3.811-45.683 0-10.022 1.268-20.08 3.811-45.763-31.89-18.385-22.517-12.982-43.584-25.125a207.1 207.1 0 0 1 36.4-63.111c23.8 13.773 11.26 6.515 43.656 25.264 42.056-30.395 32.33-24.731 79.174-45.887V51.18A210 210 0 0 1 256.172 48c15.425 0 27.954 1.694 36.359 3.176v50.242c46.955 21.206 37.182 15.538 79.174 45.887l43.638-25.254a207.4 207.4 0 0 1 36.405 63.109c-21.126 12.177-11.844 6.826-43.571 25.117 2.539 25.64 3.811 35.644 3.811 45.683 0 10.022-1.268 20.08-3.811 45.763 31.89 18.385 22.517 12.982 43.584 25.125a207.1 207.1 0 0 1-36.4 63.111M256.133 160c-52.875 0-96 43.125-96 96s43.125 96 96 96 96-43.125 96-96-43.125-96-96-96m0 144c-26.467 0-48-21.533-48-48s21.533-48 48-48 48 21.533 48 48-21.534 48-48 48" />
+ </svg>
+);
+export default SvgGear;
diff --git a/src/components/svg/Globe.tsx b/src/components/svg/Globe.tsx
new file mode 100644
index 0000000..385017d
--- /dev/null
+++ b/src/components/svg/Globe.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgGlobe = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" {...props}>
+ <path d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8m179.3 160h-67.2c-6.7-36.5-17.5-68.8-31.2-94.7 42.9 19 77.7 52.7 98.4 94.7M248 56c18.6 0 48.6 41.2 63.2 112H184.8C199.4 97.2 229.4 56 248 56M48 256c0-13.7 1.4-27.1 4-40h77.7c-1 13.1-1.7 26.3-1.7 40s.7 26.9 1.7 40H52c-2.6-12.9-4-26.3-4-40m20.7 88h67.2c6.7 36.5 17.5 68.8 31.2 94.7-42.9-19-77.7-52.7-98.4-94.7m67.2-176H68.7c20.7-42 55.5-75.7 98.4-94.7-13.7 25.9-24.5 58.2-31.2 94.7M248 456c-18.6 0-48.6-41.2-63.2-112h126.5c-14.7 70.8-44.7 112-63.3 112m70.1-160H177.9c-1.1-12.8-1.9-26-1.9-40s.8-27.2 1.9-40h140.3c1.1 12.8 1.9 26 1.9 40s-.9 27.2-2 40m10.8 142.7c13.7-25.9 24.4-58.2 31.2-94.7h67.2c-20.7 42-55.5 75.7-98.4 94.7M366.3 296c1-13.1 1.7-26.3 1.7-40s-.7-26.9-1.7-40H444c2.6 12.9 4 26.3 4 40s-1.4 27.1-4 40z" />
+ </svg>
+);
+export default SvgGlobe;
diff --git a/src/components/svg/Lightbulb.tsx b/src/components/svg/Lightbulb.tsx
new file mode 100644
index 0000000..8d86170
--- /dev/null
+++ b/src/components/svg/Lightbulb.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from 'react';
+
+const SvgLightbulb = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ xmlSpace="preserve"
+ fill="currentColor"
+ viewBox="0 0 512 512"
+ {...props}
+ >
+ <path d="M223.718 124.76c-48.027 11.198-86.688 49.285-98.494 97.031-11.843 47.899 1.711 96.722 36.259 130.601C173.703 364.377 181 383.586 181 403.777V407c0 13.296 5.801 25.26 15 33.505V467c0 24.813 20.187 45 45 45h30c24.813 0 45-20.187 45-45v-26.495c9.199-8.245 15-20.208 15-33.505v-3.282c0-19.884 7.687-39.458 20.563-52.361C376.994 325.87 391 292.005 391 256c0-86.079-79.769-151.638-167.282-131.24M286 467c0 8.271-6.729 15-15 15h-30c-8.271 0-15-6.729-15-15v-15h60zm44.326-136.834C311.689 348.843 301 375.651 301 403.718V407c0 8.271-6.729 15-15 15h-60c-8.271 0-15-6.729-15-15v-3.223c0-28.499-10.393-55.035-28.513-72.804-26.89-26.37-37.409-64.493-28.141-101.981 9.125-36.907 39.029-66.353 76.184-75.015C299.202 137.964 361 189.228 361 256c0 28.004-10.894 54.343-30.674 74.166M139.327 118.114 96.9 75.688c-5.857-5.858-15.355-5.858-21.213 0s-5.858 15.355 0 21.213l42.427 42.426c5.857 5.858 15.356 5.858 21.213 0s5.858-15.355 0-21.213M76 241H15c-8.284 0-15 6.716-15 15s6.716 15 15 15h61c8.284 0 15-6.716 15-15s-6.716-15-15-15m421 0h-61c-8.284 0-15 6.716-15 15s6.716 15 15 15h61c8.284 0 15-6.716 15-15s-6.716-15-15-15M436.313 75.688c-5.856-5.858-15.354-5.858-21.213 0l-42.427 42.426c-5.858 5.857-5.858 15.355 0 21.213s15.355 5.858 21.213 0l42.427-42.426c5.858-5.857 5.858-15.355 0-21.213M256 0c-8.284 0-15 6.716-15 15v61c0 8.284 6.716 15 15 15s15-6.716 15-15V15c0-8.284-6.716-15-15-15" />
+ <path d="M256 181c-6.166 0-12.447.739-18.658 2.194-25.865 6.037-47.518 27.328-53.879 52.979-1.994 8.041 2.907 16.175 10.947 18.17 8.042 1.994 16.176-2.909 18.17-10.948 3.661-14.758 16.647-27.5 31.593-30.989 3.982-.933 7.962-1.406 11.827-1.406 8.284 0 15-6.716 15-15s-6.716-15-15-15" />
+ </svg>
+);
+export default SvgLightbulb;
diff --git a/src/components/svg/Lightning.tsx b/src/components/svg/Lightning.tsx
new file mode 100644
index 0000000..9539a96
--- /dev/null
+++ b/src/components/svg/Lightning.tsx
@@ -0,0 +1,33 @@
+import type { SVGProps } from 'react';
+
+const SvgLightning = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ xmlSpace="preserve"
+ viewBox="0 0 682.667 682.667"
+ {...props}
+ >
+ <defs>
+ <clipPath id="lightning_svg__a" clipPathUnits="userSpaceOnUse">
+ <path d="M0 512h512V0H0Z" />
+ </clipPath>
+ </defs>
+ <g clipPath="url(#lightning_svg__a)" transform="matrix(1.33333 0 0 -1.33333 0 682.667)">
+ <path
+ d="M0 0h137.962L69.319-155.807h140.419L.242-482l55.349 222.794h-155.853z"
+ style={{
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: 30,
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ strokeMiterlimit: 10,
+ strokeDasharray: 'none',
+ strokeOpacity: 1,
+ }}
+ transform="translate(201.262 496.994)"
+ />
+ </g>
+ </svg>
+);
+export default SvgLightning;
diff --git a/src/components/svg/Link.tsx b/src/components/svg/Link.tsx
new file mode 100644
index 0000000..4ce88e7
--- /dev/null
+++ b/src/components/svg/Link.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgLink = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path d="M314.222 197.78c51.091 51.091 54.377 132.287 9.75 187.16-6.242 7.73-2.784 3.865-84.94 86.02-54.696 54.696-143.266 54.745-197.99 0-54.711-54.69-54.734-143.255 0-197.99 32.773-32.773 51.835-51.899 63.409-63.457 7.463-7.452 20.331-2.354 20.486 8.192a173.3 173.3 0 0 0 4.746 37.828c.966 4.029-.272 8.269-3.202 11.198L80.632 312.57c-32.755 32.775-32.887 85.892 0 118.8 32.775 32.755 85.892 32.887 118.8 0l75.19-75.2c32.718-32.725 32.777-86.013 0-118.79a83.7 83.7 0 0 0-22.814-16.229c-4.623-2.233-7.182-7.25-6.561-12.346 1.356-11.122 6.296-21.885 14.815-30.405l4.375-4.375c3.625-3.626 9.177-4.594 13.76-2.294 12.999 6.524 25.187 15.211 36.025 26.049M470.958 41.04c-54.724-54.745-143.294-54.696-197.99 0-82.156 82.156-78.698 78.29-84.94 86.02-44.627 54.873-41.341 136.069 9.75 187.16 10.838 10.838 23.026 19.525 36.025 26.049 4.582 2.3 10.134 1.331 13.76-2.294l4.375-4.375c8.52-8.519 13.459-19.283 14.815-30.405.621-5.096-1.938-10.113-6.561-12.346a83.7 83.7 0 0 1-22.814-16.229c-32.777-32.777-32.718-86.065 0-118.79l75.19-75.2c32.908-32.887 86.025-32.755 118.8 0 32.887 32.908 32.755 86.025 0 118.8l-45.848 45.84c-2.93 2.929-4.168 7.169-3.202 11.198a173.3 173.3 0 0 1 4.746 37.828c.155 10.546 13.023 15.644 20.486 8.192 11.574-11.558 30.636-30.684 63.409-63.457 54.733-54.735 54.71-143.3-.001-197.991" />
+ </svg>
+);
+export default SvgLink;
diff --git a/src/components/svg/Location.tsx b/src/components/svg/Location.tsx
new file mode 100644
index 0000000..0fd7d16
--- /dev/null
+++ b/src/components/svg/Location.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgLocation = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 64 64" {...props}>
+ <path d="M32 0A24.03 24.03 0 0 0 8 24c0 17.23 22.36 38.81 23.31 39.72a.99.99 0 0 0 1.38 0C33.64 62.81 56 41.23 56 24A24.03 24.03 0 0 0 32 0m0 35a11 11 0 1 1 11-11 11.007 11.007 0 0 1-11 11" />
+ </svg>
+);
+export default SvgLocation;
diff --git a/src/components/svg/Lock.tsx b/src/components/svg/Lock.tsx
new file mode 100644
index 0000000..2b62eb9
--- /dev/null
+++ b/src/components/svg/Lock.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgLock = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}>
+ <path d="M18.75 9H18V6c0-3.309-2.691-6-6-6S6 2.691 6 6v3h-.75A2.253 2.253 0 0 0 3 11.25v10.5C3 22.991 4.01 24 5.25 24h13.5c1.24 0 2.25-1.009 2.25-2.25v-10.5C21 10.009 19.99 9 18.75 9M8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v3H8zm5 10.722V19a1 1 0 1 1-2 0v-2.278c-.595-.347-1-.985-1-1.722 0-1.103.897-2 2-2s2 .897 2 2c0 .737-.405 1.375-1 1.722" />
+ </svg>
+);
+export default SvgLock;
diff --git a/src/components/svg/Logo.tsx b/src/components/svg/Logo.tsx
new file mode 100644
index 0000000..eb9fdf5
--- /dev/null
+++ b/src/components/svg/Logo.tsx
@@ -0,0 +1,17 @@
+import type { SVGProps } from 'react';
+
+const SvgLogo = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={20}
+ height={20}
+ fill="currentColor"
+ stroke="currentColor"
+ viewBox="0 0 428 389.11"
+ {...props}
+ >
+ <circle cx={214.15} cy={181} r={171} fill="none" strokeMiterlimit={10} strokeWidth={20} />
+ <path d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15Z" />
+ </svg>
+);
+export default SvgLogo;
diff --git a/src/components/svg/LogoWhite.tsx b/src/components/svg/LogoWhite.tsx
new file mode 100644
index 0000000..fb8c5f9
--- /dev/null
+++ b/src/components/svg/LogoWhite.tsx
@@ -0,0 +1,26 @@
+import type { SVGProps } from 'react';
+
+const SvgLogoWhite = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={20}
+ height={20}
+ viewBox="0 0 428 389.11"
+ {...props}
+ >
+ <circle
+ cx={214.15}
+ cy={181}
+ r={171}
+ fill="none"
+ stroke="#fff"
+ strokeMiterlimit={10}
+ strokeWidth={20}
+ />
+ <path
+ fill="#fff"
+ d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15"
+ />
+ </svg>
+);
+export default SvgLogoWhite;
diff --git a/src/components/svg/Magnet.tsx b/src/components/svg/Magnet.tsx
new file mode 100644
index 0000000..88b0f03
--- /dev/null
+++ b/src/components/svg/Magnet.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from 'react';
+
+const SvgMagnet = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ fill="currentColor"
+ viewBox="0 0 508.467 508.467"
+ {...props}
+ >
+ <path d="M426.815 239.006c-11.722-11.724-30.702-11.729-42.427-.001L267.67 355.723c-53.811 53.809-142.478 19.197-140.68-54.511.547-22.415 9.826-43.738 26.129-60.041l116.717-116.717c11.724-11.722 11.728-30.702 0-42.427l-46.668-46.669c-11.725-11.725-30.702-11.726-42.427 0L60.629 155.47C21.579 194.52.047 246.44 0 301.665c-.093 110.827 88.182 206.288 206.244 206.394 56.778 0 109.204-21.924 148.29-61.01l118.948-118.948c11.724-11.722 11.728-30.702 0-42.427zM201.954 56.572l46.669 46.669-58.455 58.456-46.669-46.669zm131.367 369.264c-69.043 69.043-182.868 70.02-251.708.933-68.763-69.009-68.66-181.196.229-250.086l40.443-40.443 46.669 46.669-37.049 37.049c-45.115 45.112-46.916 116.85-3.395 160.371 43.279 43.279 115.221 41.756 160.372-3.394l37.049-37.049 46.669 46.669zm60.494-60.493-46.669-46.669 58.456-58.456 46.669 46.669zM379.357 95.099c15.199 3.839 30.418 19.07 34.336 34.192 2.089 8.058 10.303 12.828 18.283 10.758 8.02-2.078 12.836-10.264 10.758-18.283-6.651-25.662-30.176-49.223-56.03-55.753-8.032-2.027-16.188 2.838-18.217 10.869-2.029 8.032 2.837 16.189 10.87 18.217m128.627 7.025C495.968 55.749 452.769 12.62 406.239.868c-8.032-2.027-16.188 2.838-18.217 10.869-2.029 8.032 2.838 16.188 10.87 18.217 35.882 9.063 70.769 43.871 80.051 79.695 2.088 8.058 10.304 12.828 18.283 10.758 8.02-2.078 12.836-10.263 10.758-18.283" />
+ </svg>
+);
+export default SvgMagnet;
diff --git a/src/components/svg/Money.tsx b/src/components/svg/Money.tsx
new file mode 100644
index 0000000..7d7b1e5
--- /dev/null
+++ b/src/components/svg/Money.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from 'react';
+
+const SvgMoney = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ xmlSpace="preserve"
+ fill="currentColor"
+ viewBox="0 0 512 512"
+ {...props}
+ >
+ <path d="M347 302c8.271 0 15 6.639 15 14.8h30c0-19.468-12.541-36.067-30-42.231V242h-30v32.58c-17.459 6.192-30 22.865-30 42.42 0 24.813 20.187 45 45 45 8.271 0 15 6.729 15 15s-6.729 15-15 15-15-6.729-15-15h-30c0 19.555 12.541 36.228 30 42.42v32.38h30v-32.38c17.459-6.192 30-22.865 30-42.42 0-24.813-20.187-45-45-45-8.271 0-15-6.729-15-15s6.729-15 15-15" />
+ <path d="M347 182c-5.057 0-10.058.242-15 .689V90c0-26.011-18.548-49.61-52.226-66.449C249.4 8.364 209.35 0 167 0 124.564 0 84.193 8.347 53.323 23.502 18.938 40.385 0 64 0 90v272c0 26 18.938 49.616 53.323 66.498C84.193 443.653 124.564 452 167 452c17.009 0 33.647-1.358 49.615-4.004C246.826 486.909 294.035 512 347 512c90.981 0 165-74.019 165-165s-74.019-165-165-165M66.545 50.432C92.992 37.447 129.606 30 167 30c79.558 0 135 31.621 135 60s-55.442 60-135 60c-37.394 0-74.008-7.447-100.455-20.432C43.32 118.166 30 103.744 30 90s13.32-28.166 36.545-39.568M30 142.265c6.724 5.137 14.512 9.907 23.323 14.233C84.193 171.653 124.564 180 167 180c42.35 0 82.4-8.364 112.774-23.551 8.359-4.18 15.783-8.776 22.226-13.722v45.51c-29.896 8.485-56.359 25.209-76.778 47.548C206.946 239.908 187.386 242 167 242c-37.394 0-74.008-7.447-100.455-20.432C43.32 210.166 30 195.744 30 182zm0 92c6.724 5.137 14.512 9.907 23.323 14.233C84.193 263.653 124.564 272 167 272c11.581 0 22.942-.621 34.021-1.839a163.7 163.7 0 0 0-18.293 61.395c-5.211.286-10.465.444-15.728.444-37.394 0-74.008-7.447-100.455-20.432C43.32 300.166 30 285.744 30 272zM167 422c-37.394 0-74.008-7.447-100.455-20.432C43.32 390.166 30 375.744 30 362v-37.736c6.724 5.137 14.512 9.907 23.323 14.233C84.193 353.653 124.564 362 167 362c5.23 0 10.459-.132 15.654-.388a163.7 163.7 0 0 0 16.486 58.557A281 281 0 0 1 167 422m180 60c-74.439 0-135-60.561-135-135s60.561-135 135-135 135 60.561 135 135-60.561 135-135 135" />
+ </svg>
+);
+export default SvgMoney;
diff --git a/src/components/svg/Moon.tsx b/src/components/svg/Moon.tsx
new file mode 100644
index 0000000..40e3e8b
--- /dev/null
+++ b/src/components/svg/Moon.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgMoon = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1399.98 1400" {...props}>
+ <path d="M562.44 837.55C335.89 611 288.08 273.54 418.71 0a734.3 734.3 0 0 0-203.17 143.73c-287.39 287.39-287.39 753.33 0 1040.72s753.33 287.4 1040.74 0A733.8 733.8 0 0 0 1400 981.29c-273.55 130.63-611 82.8-837.56-143.74" />
+ </svg>
+);
+export default SvgMoon;
diff --git a/src/components/svg/Network.tsx b/src/components/svg/Network.tsx
new file mode 100644
index 0000000..15941a9
--- /dev/null
+++ b/src/components/svg/Network.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from 'react';
+
+const SvgNetwork = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ fill="currentColor"
+ viewBox="0 0 32 32"
+ {...props}
+ >
+ <path d="M28 19c-.809 0-1.54.325-2.08.847l-6.011-3.01c.058-.271.091-.55.091-.837s-.033-.566-.091-.837l6.011-3.01c.54.522 1.271.847 2.08.847 1.654 0 3-1.346 3-3s-1.346-3-3-3-3 1.346-3 3c0 .123.022.24.036.359L19 13.382a3.98 3.98 0 0 0-2-1.24V6.816A3 3 0 0 0 19 4c0-1.654-1.346-3-3-3s-3 1.346-3 3c0 1.302.838 2.401 2 2.815v5.327a4 4 0 0 0-2 1.24L6.963 10.36c.015-.12.037-.237.037-.36 0-1.654-1.346-3-3-3s-3 1.346-3 3 1.346 3 3 3c.809 0 1.54-.325 2.08-.847l6.011 3.01q-.089.407-.091.837c-.002.43.033.566.091.837l-6.011 3.01A2.98 2.98 0 0 0 4 19c-1.654 0-3 1.346-3 3s1.346 3 3 3 3-1.346 3-3c0-.123-.022-.24-.036-.359L13 18.618a3.98 3.98 0 0 0 2 1.24v5.326A3 3 0 0 0 13 28c0 1.654 1.346 3 3 3s3-1.346 3-3a3 3 0 0 0-2-2.816v-5.326a4 4 0 0 0 2-1.24l6.037 3.022c-.015.12-.037.237-.037.36 0 1.654 1.346 3 3 3s3-1.346 3-3-1.346-3-3-3m0-10c.551 0 1 .449 1 1s-.449 1-1 1-1-.449-1-1 .449-1 1-1M4 11c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1m0 12c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1M16 3c.551 0 1 .449 1 1s-.449 1-1 1-1-.449-1-1 .449-1 1-1m0 26c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1m0-11c-1.103 0-2-.897-2-2s.897-2 2-2 2 .897 2 2-.897 2-2 2m12 5c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1" />
+ </svg>
+);
+export default SvgNetwork;
diff --git a/src/components/svg/Nodes.tsx b/src/components/svg/Nodes.tsx
new file mode 100644
index 0000000..1adfcb8
--- /dev/null
+++ b/src/components/svg/Nodes.tsx
@@ -0,0 +1,12 @@
+import type { SVGProps } from 'react';
+
+const SvgNodes = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}>
+ <path
+ fillRule="evenodd"
+ d="M19 9.874A4.002 4.002 0 0 0 18 2a4 4 0 0 0-3.874 3H9.874A4.002 4.002 0 0 0 2 6a4 4 0 0 0 3 3.874v4.252A4.002 4.002 0 0 0 6 22a4 4 0 0 0 3.874-3h4.252A4.002 4.002 0 0 0 22 18a4 4 0 0 0-3-3.874zM6 4a2 2 0 1 1 0 4 2 2 0 0 1 0-4m3.874 3A4.01 4.01 0 0 1 7 9.874v4.252A4.01 4.01 0 0 1 9.874 17h4.252A4.01 4.01 0 0 1 17 14.126V9.874A4.01 4.01 0 0 1 14.126 7zM18 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4m0 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4M8 18a2 2 0 1 0-4 0 2 2 0 0 0 4 0"
+ clipRule="evenodd"
+ />
+ </svg>
+);
+export default SvgNodes;
diff --git a/src/components/svg/Overview.tsx b/src/components/svg/Overview.tsx
new file mode 100644
index 0000000..67e6af1
--- /dev/null
+++ b/src/components/svg/Overview.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgOverview = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" xmlSpace="preserve" viewBox="0 0 512 512" {...props}>
+ <path d="M452 36H60C26.916 36 0 62.916 0 96v240c0 33.084 26.916 60 60 60h176v40H132v40h248v-40H276v-40h176c33.084 0 60-26.916 60-60V96c0-33.084-26.916-60-60-60m20 300c0 11.028-8.972 20-20 20H60c-11.028 0-20-8.972-20-20V96c0-11.028 8.972-20 20-20h392c11.028 0 20 8.972 20 20z" />
+ </svg>
+);
+export default SvgOverview;
diff --git a/src/components/svg/Path.tsx b/src/components/svg/Path.tsx
new file mode 100644
index 0000000..7538ba4
--- /dev/null
+++ b/src/components/svg/Path.tsx
@@ -0,0 +1,15 @@
+import type { SVGProps } from 'react';
+
+const SvgPath = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ fill="currentColor"
+ viewBox="0 0 64 64"
+ {...props}
+ >
+ <path d="m56.4 47.6-6-6c-.8-.8-2-.8-2.8 0s-.8 2 0 2.8l2.6 2.6H18.5c-3.6 0-6.5-2.9-6.5-6.5s2.9-6.5 6.5-6.5h27C51.3 34 56 29.3 56 23.5S51.3 13 45.5 13H22.7c-.9-3.4-4-6-7.7-6-4.4 0-8 3.6-8 8s3.6 8 8 8c3.7 0 6.8-2.6 7.7-6h22.8c3.6 0 6.5 2.9 6.5 6.5S49.1 30 45.5 30h-27C12.7 30 8 34.7 8 40.5S12.7 51 18.5 51h31.7l-2.6 2.6c-.8.8-.8 2 0 2.8.4.4.9.6 1.4.6s1-.2 1.4-.6l6-6c.8-.8.8-2 0-2.8M15 19c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4" />
+ </svg>
+);
+export default SvgPath;
diff --git a/src/components/svg/Profile.tsx b/src/components/svg/Profile.tsx
new file mode 100644
index 0000000..c955fce
--- /dev/null
+++ b/src/components/svg/Profile.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgProfile = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path d="M437.02 74.98C388.668 26.63 324.379 0 256 0S123.332 26.629 74.98 74.98C26.63 123.332 0 187.621 0 256s26.629 132.668 74.98 181.02C123.332 485.37 187.621 512 256 512s132.668-26.629 181.02-74.98C485.37 388.668 512 324.379 512 256s-26.629-132.668-74.98-181.02M111.105 429.297c8.454-72.735 70.989-128.89 144.895-128.89 38.96 0 75.598 15.179 103.156 42.734 23.281 23.285 37.965 53.687 41.742 86.152C361.641 462.172 311.094 482 256 482s-105.637-19.824-144.895-52.703M256 269.507c-42.871 0-77.754-34.882-77.754-77.753C178.246 148.879 213.13 114 256 114s77.754 34.879 77.754 77.754c0 42.871-34.883 77.754-77.754 77.754zm170.719 134.427a175.9 175.9 0 0 0-46.352-82.004c-18.437-18.438-40.25-32.27-64.039-40.938 28.598-19.394 47.426-52.16 47.426-89.238C363.754 132.34 315.414 84 256 84s-107.754 48.34-107.754 107.754c0 37.098 18.844 69.875 47.465 89.266-21.887 7.976-42.14 20.308-59.566 36.542-25.235 23.5-42.758 53.465-50.883 86.348C50.852 364.242 30 312.512 30 256 30 131.383 131.383 30 256 30s226 101.383 226 226c0 56.523-20.86 108.266-55.281 147.934m0 0" />
+ </svg>
+);
+export default SvgProfile;
diff --git a/src/components/svg/Pushpin.tsx b/src/components/svg/Pushpin.tsx
new file mode 100644
index 0000000..d19e98e
--- /dev/null
+++ b/src/components/svg/Pushpin.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgPushpin = (props: SVGProps<SVGSVGElement>) => (
+ <svg width="1em" height="1em" fill="currentColor" viewBox="0 0 1024 1024" {...props}>
+ <path d="M878.3 392.1 631.9 145.7c-6.5-6.5-15-9.7-23.5-9.7s-17 3.2-23.5 9.7L423.8 306.9c-12.2-1.4-24.5-2-36.8-2-73.2 0-146.4 24.1-206.5 72.3-15.4 12.3-16.6 35.4-2.7 49.4l181.7 181.7-215.4 215.2a15.8 15.8 0 0 0-4.6 9.8l-3.4 37.2c-.9 9.4 6.6 17.4 15.9 17.4.5 0 1 0 1.5-.1l37.2-3.4c3.7-.3 7.2-2 9.8-4.6l215.4-215.4 181.7 181.7c6.5 6.5 15 9.7 23.5 9.7 9.7 0 19.3-4.2 25.9-12.4 56.3-70.3 79.7-158.3 70.2-243.4l161.1-161.1c12.9-12.8 12.9-33.8 0-46.8" />
+ </svg>
+);
+export default SvgPushpin;
diff --git a/src/components/svg/Redo.tsx b/src/components/svg/Redo.tsx
new file mode 100644
index 0000000..04c389f
--- /dev/null
+++ b/src/components/svg/Redo.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgRedo = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path d="M500 8h-27.711c-6.739 0-12.157 5.548-11.997 12.286l2.347 98.568C418.075 51.834 341.788 7.73 255.207 8.001 118.82 8.428 7.787 120.009 8 256.396 8.214 393.181 119.165 504 256 504c63.926 0 122.202-24.187 166.178-63.908 5.113-4.618 5.354-12.561.482-17.433l-19.738-19.738c-4.498-4.498-11.753-4.785-16.501-.552C351.787 433.246 306.105 452 256 452c-108.322 0-196-87.662-196-196 0-108.322 87.662-196 196-196 79.545 0 147.941 47.282 178.675 115.302l-126.389-3.009c-6.737-.16-12.286 5.257-12.286 11.997V212c0 6.627 5.373 12 12 12h192c6.627 0 12-5.373 12-12V20c0-6.627-5.373-12-12-12" />
+ </svg>
+);
+export default SvgRedo;
diff --git a/src/components/svg/Reports.tsx b/src/components/svg/Reports.tsx
new file mode 100644
index 0000000..b548966
--- /dev/null
+++ b/src/components/svg/Reports.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgReports = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" {...props}>
+ <path d="M61.17 18.91A32 32 0 1 0 46.4 60.54l.15-.06.16-.1a31.93 31.93 0 0 0 14.47-41.44s-.01-.02-.01-.03m-4.53-.16L34 28.91V4.1a28 28 0 0 1 22.64 14.65M4 32A28 28 0 0 1 30 4.1V32a1.7 1.7 0 0 0 0 .39.2.2 0 0 0 0 .07 1.5 1.5 0 0 0 .15.4l12.76 24.9A28 28 0 0 1 4 32m42.47 23.94L34.74 33l23.54-10.6a28 28 0 0 1-11.81 33.54" />
+ </svg>
+);
+export default SvgReports;
diff --git a/src/components/svg/Security.tsx b/src/components/svg/Security.tsx
new file mode 100644
index 0000000..d075a93
--- /dev/null
+++ b/src/components/svg/Security.tsx
@@ -0,0 +1,16 @@
+import type { SVGProps } from 'react';
+
+const SvgSecurity = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ data-name="Layer 1"
+ viewBox="0 0 36 36"
+ {...props}
+ >
+ <path d="M18 34a1.1 1.1 0 0 1-.48-.11l-4.87-2.43A13.79 13.79 0 0 1 5 19.05V6.91a1.07 1.07 0 0 1 1.05-1.07h3.47a7.45 7.45 0 0 0 4-1.19l3.87-2.48a1.07 1.07 0 0 1 1.15 0l3.87 2.48a7.45 7.45 0 0 0 4 1.19h3.47A1.07 1.07 0 0 1 31 6.91v12.14a13.79 13.79 0 0 1-7.67 12.4l-4.87 2.43A1.1 1.1 0 0 1 18 34M7.12 8v11.05a11.67 11.67 0 0 0 6.49 10.49l4.39 2.2 4.39-2.2a11.67 11.67 0 0 0 6.49-10.49V8h-2.4a9.57 9.57 0 0 1-5.19-1.53L18 4.33l-3.29 2.12A9.57 9.57 0 0 1 9.52 8z" />
+ <path d="M18 18.8a4.8 4.8 0 1 1 4.8-4.8 4.81 4.81 0 0 1-4.8 4.8m0-7.47A2.67 2.67 0 1 0 20.67 14 2.67 2.67 0 0 0 18 11.34zM24.4 24.67h-2.13a2.14 2.14 0 0 0-2.13-2.13h-4.28a2.13 2.13 0 0 0-2.13 2.13H11.6a4.26 4.26 0 0 1 4.26-4.26h4.27a4.27 4.27 0 0 1 4.27 4.26" />
+ </svg>
+);
+export default SvgSecurity;
diff --git a/src/components/svg/Speaker.tsx b/src/components/svg/Speaker.tsx
new file mode 100644
index 0000000..eb724ae
--- /dev/null
+++ b/src/components/svg/Speaker.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgSpeaker = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}>
+ <path d="M232.011 88.828c-5.664-5.664-13.217-8.784-21.269-8.784s-15.605 3.12-21.269 8.783c-9.917 9.917-11.446 25.09-4.593 36.632-23.293 86.372-34.167 96.094-78.604 135.776-15.831 14.138-35.533 31.731-61.302 57.5-5.434 5.434-8.426 12.673-8.426 20.383s2.993 14.949 8.426 20.383l70.981 70.98c5.434 5.435 12.672 8.427 20.382 8.427a28.7 28.7 0 0 0 14.046-3.637l72.768 72.768c2.574 2.574 6.09 3.962 9.896 3.961q1.185 0 2.398-.181c3.883-.581 7.662-2.543 10.641-5.521l25.329-25.329c6.918-6.919 7.684-16.993 1.741-22.936l-39.164-39.164c11.586-20.762 9.203-46.431-6.187-64.762 29.684-32.251 46.532-43.128 122.192-63.532a30.1 30.1 0 0 0 15.361 4.203c7.703 0 15.405-2.933 21.269-8.796 11.728-11.729 11.728-30.811 0-42.539zM127.268 419.167l-70.981-70.981c-2.412-2.411-3.74-5.632-3.74-9.068s1.328-6.657 3.74-9.068c17.786-17.786 32.665-31.645 45.371-43.163l86.911 86.911c-11.519 12.706-25.378 27.585-43.164 45.371-2.412 2.411-5.632 3.74-9.068 3.74-3.437-.001-6.657-1.33-9.069-3.742M260.1 469.653l-25.33 25.33a4.1 4.1 0 0 1-1.197.85L162.45 424.71a1244 1244 0 0 0 26.786-27.968l71.714 71.713a4 4 0 0 1-.85 1.198m-38.055-62.731-21.982-21.981a2608 2608 0 0 0 14.157-15.763l2.712-3.035c8.895 11.831 10.752 27.329 5.113 40.779m-19.759-48.401-3.004 3.362-85.711-85.711 3.361-3.003c44.419-39.665 57.85-51.661 80.687-133.656l138.322 138.322c-81.993 22.837-93.99 36.268-133.655 80.686m173.027-83.854c-5.489 5.49-14.422 5.49-19.911 0L200.786 120.052c-5.489-5.489-5.489-14.421 0-19.91 2.642-2.643 6.178-4.098 9.956-4.098s7.313 1.455 9.955 4.098l154.616 154.615c5.489 5.489 5.489 14.421 0 19.91m-22.558-151.968a8 8 0 0 1 0-11.314l43.904-43.904a8 8 0 0 1 11.313 11.314l-43.904 43.904c-1.562 1.562-3.609 2.343-5.657 2.343s-4.094-.781-5.656-2.343m122.699 107.695a8 8 0 0 1-8 8h-62.09a8 8 0 0 1 0-16h62.09a8 8 0 0 1 8 8M237.061 70.09V8a8 8 0 0 1 16 0v62.09a8 8 0 0 1-16 0" />
+ </svg>
+);
+export default SvgSpeaker;
diff --git a/src/components/svg/Sun.tsx b/src/components/svg/Sun.tsx
new file mode 100644
index 0000000..61880f5
--- /dev/null
+++ b/src/components/svg/Sun.tsx
@@ -0,0 +1,9 @@
+import type { SVGProps } from 'react';
+
+const SvgSun = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400" {...props}>
+ <path d="M367.43 422.13a54.44 54.44 0 0 1-38.66-16L205 282.35A54.69 54.69 0 0 1 282.37 205l123.74 123.79a54.68 54.68 0 0 1-38.68 93.34M1156.3 1211a54.5 54.5 0 0 1-38.67-16l-123.74-123.79a54.68 54.68 0 1 1 77.34-77.33L1195 1117.65a54.7 54.7 0 0 1-38.7 93.35m-912.6 0a54.7 54.7 0 0 1-38.7-93.35l123.74-123.76a54.69 54.69 0 0 1 77.36 77.32L282.37 1195a54.5 54.5 0 0 1-38.67 16m788.87-788.87a54.68 54.68 0 0 1-38.68-93.34L1117.61 205a54.69 54.69 0 0 1 77.39 77.35l-123.77 123.76a54.44 54.44 0 0 1-38.66 16.02M229.69 754.69h-175a54.69 54.69 0 0 1 0-109.38h175a54.69 54.69 0 0 1 0 109.38m1115.62 0h-175a54.69 54.69 0 0 1 0-109.38h175a54.69 54.69 0 0 1 0 109.38M700 1400a54.68 54.68 0 0 1-54.69-54.69v-175a54.69 54.69 0 0 1 109.38 0v175A54.68 54.68 0 0 1 700 1400m0-1115.62a54.7 54.7 0 0 1-54.69-54.69v-175a54.69 54.69 0 0 1 109.38 0v175A54.7 54.7 0 0 1 700 284.38" />
+ <circle cx={700} cy={700} r={306.25} />
+ </svg>
+);
+export default SvgSun;
diff --git a/src/components/svg/Switch.tsx b/src/components/svg/Switch.tsx
new file mode 100644
index 0000000..0196d85
--- /dev/null
+++ b/src/components/svg/Switch.tsx
@@ -0,0 +1,19 @@
+import type { SVGProps } from 'react';
+
+const SvgSwitch = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={200}
+ height={200}
+ fill="none"
+ stroke="currentColor"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ viewBox="0 0 24 24"
+ {...props}
+ >
+ <path d="m16 3 4 4-4 4M10 7h10M8 13l-4 4 4 4M4 17h9" />
+ </svg>
+);
+export default SvgSwitch;
diff --git a/src/components/svg/Tag.tsx b/src/components/svg/Tag.tsx
new file mode 100644
index 0000000..2ff51f4
--- /dev/null
+++ b/src/components/svg/Tag.tsx
@@ -0,0 +1,16 @@
+import type { SVGProps } from 'react';
+
+const SvgTag = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="437pt"
+ height="437pt"
+ fill="currentColor"
+ viewBox="0 0 437.004 437"
+ {...props}
+ >
+ <path d="M229 14.645A50.17 50.17 0 0 0 192.371.015L52.293 3.586C25.672 4.25 4.246 25.673 3.582 52.298L.016 192.37a50.22 50.22 0 0 0 14.625 36.633l193.367 193.36c19.539 19.495 51.168 19.495 70.707 0l143.644-143.645c19.528-19.524 19.528-51.184 0-70.711zm179.219 249.933-143.645 143.64c-11.722 11.7-30.703 11.7-42.426 0L28.785 214.86a30.13 30.13 0 0 1-8.777-21.98l3.566-140.074c.403-15.973 13.254-28.828 29.227-29.227l140.074-3.57c.254-.004.5-.008.754-.008a30.13 30.13 0 0 1 21.223 8.79l193.367 193.362c11.695 11.723 11.695 30.703 0 42.426zm0 0" />
+ <path d="M130.719 82.574c-26.59 0-48.145 21.555-48.149 48.145 0 26.59 21.559 48.144 48.145 48.144 26.59 0 48.144-21.554 48.144-48.144-.03-26.574-21.566-48.114-48.14-48.145m0 76.29c-15.547 0-28.145-12.602-28.149-28.145 0-15.543 12.602-28.145 28.145-28.145s28.144 12.602 28.144 28.145c-.015 15.535-12.605 28.125-28.14 28.144zm0 0" />
+ </svg>
+);
+export default SvgTag;
diff --git a/src/components/svg/Target.tsx b/src/components/svg/Target.tsx
new file mode 100644
index 0000000..3fe76d2
--- /dev/null
+++ b/src/components/svg/Target.tsx
@@ -0,0 +1,21 @@
+import type { SVGProps } from 'react';
+
+const SvgTarget = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width={512}
+ height={512}
+ fill="currentColor"
+ fillRule="evenodd"
+ strokeLinejoin="round"
+ strokeMiterlimit={2}
+ clipRule="evenodd"
+ viewBox="0 0 24 24"
+ {...props}
+ >
+ <path d="M19.393 10.825a.75.75 0 0 1 1.458-.352c.181.75.277 1.533.277 2.338 0 5.485-4.453 9.939-9.939 9.939S1.25 18.296 1.25 12.811s4.454-9.939 9.939-9.939c.805 0 1.588.096 2.338.277a.75.75 0 1 1-.352 1.458A8.442 8.442 0 0 0 2.75 12.811a8.44 8.44 0 0 0 8.439 8.439 8.442 8.442 0 0 0 8.204-10.425" />
+ <path d="M14.764 12.811a.75.75 0 0 1 1.5 0c0 2.8-2.274 5.074-5.075 5.074a5.077 5.077 0 0 1-5.074-5.074 5.077 5.077 0 0 1 5.074-5.075.75.75 0 0 1 0 1.5 3.575 3.575 0 1 0 3.575 3.575m7.766-7.223-3.057 3.058a.75.75 0 0 1-.531.22h-3.058a.75.75 0 0 1-.75-.75V5.058a.75.75 0 0 1 .22-.531l3.058-3.057a.75.75 0 0 1 1.242.293L20.3 3.7l1.937.646a.75.75 0 0 1 .293 1.242m-1.918-.202-1.142-.381a.75.75 0 0 1-.475-.475l-.381-1.142-1.98 1.98v1.998h1.998z" />
+ <path d="M15.354 7.585a.75.75 0 1 1 1.061 1.061l-4.587 4.586a.749.749 0 1 1-1.06-1.06z" />
+ </svg>
+);
+export default SvgTarget;
diff --git a/src/components/svg/Visitor.tsx b/src/components/svg/Visitor.tsx
new file mode 100644
index 0000000..16db585
--- /dev/null
+++ b/src/components/svg/Visitor.tsx
@@ -0,0 +1,8 @@
+import type { SVGProps } from 'react';
+
+const SvgVisitor = (props: SVGProps<SVGSVGElement>) => (
+ <svg xmlns="http://www.w3.org/2000/svg" xmlSpace="preserve" viewBox="0 0 512 512" {...props}>
+ <path d="M256 0c-74.439 0-135 60.561-135 135s60.561 135 135 135 135-60.561 135-135S330.439 0 256 0m167.966 358.195C387.006 320.667 338.009 300 286 300h-60c-52.008 0-101.006 20.667-137.966 58.195C51.255 395.539 31 444.833 31 497c0 8.284 6.716 15 15 15h420c8.284 0 15-6.716 15-15 0-52.167-20.255-101.461-57.034-138.805" />
+ </svg>
+);
+export default SvgVisitor;
diff --git a/src/components/svg/Website.tsx b/src/components/svg/Website.tsx
new file mode 100644
index 0000000..20a18a4
--- /dev/null
+++ b/src/components/svg/Website.tsx
@@ -0,0 +1,13 @@
+import type { SVGProps } from 'react';
+
+const SvgWebsite = (props: SVGProps<SVGSVGElement>) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ xmlSpace="preserve"
+ viewBox="0 0 511.999 511.999"
+ {...props}
+ >
+ <path d="M437.019 74.981C388.667 26.628 324.38 0 256 0S123.332 26.628 74.981 74.98C26.628 123.332 0 187.62 0 256s26.628 132.667 74.981 181.019c48.351 48.352 112.639 74.98 181.019 74.98s132.667-26.628 181.02-74.981C485.371 388.667 512 324.379 512 255.999s-26.629-132.667-74.981-181.018M96.216 96.216c22.511-22.511 48.938-39.681 77.742-50.888-7.672 9.578-14.851 20.587-21.43 32.969-7.641 14.38-14.234 30.173-19.725 47.042-19.022-3.157-36.647-7.039-52.393-11.595a230 230 0 0 1 15.806-17.528m-33.987 43.369c18.417 5.897 39.479 10.87 62.461 14.809-6.4 27.166-10.167 56.399-11.066 86.591H30.536c2.36-36.233 13.242-70.813 31.693-101.4m-1.635 230.053c-17.455-29.899-27.769-63.481-30.059-98.623h83.146c.982 29.329 4.674 57.731 10.858 84.186-23.454 3.802-45.045 8.649-63.945 14.437m35.622 46.146a230 230 0 0 1-17.831-20.055c16.323-4.526 34.571-8.359 54.214-11.433 5.53 17.103 12.194 33.105 19.928 47.662 7.17 13.493 15.053 25.349 23.51 35.505-29.61-11.183-56.769-28.629-79.821-51.679m144.768 62.331c-22.808-6.389-44.384-27.217-61.936-60.249-6.139-11.552-11.531-24.155-16.15-37.587 24.73-2.722 51.045-4.331 78.086-4.709zm0-132.578c-29.988.409-59.217 2.292-86.59 5.507-6.038-24.961-9.671-51.978-10.668-80.028h97.259v74.521zm0-104.553h-97.315c.911-28.834 4.602-56.605 10.828-82.201 27.198 3.4 56.366 5.468 86.487 6.06zm0-106.176c-27.146-.547-53.403-2.317-77.958-5.205 4.591-13.292 9.941-25.768 16.022-37.215 17.551-33.032 39.128-53.86 61.936-60.249zm209.733 6.372c17.874 30.193 28.427 64.199 30.749 99.804h-83.088c-.889-29.844-4.584-58.749-10.85-85.647 23.133-3.736 44.456-8.489 63.189-14.157m-34.934-44.964a230 230 0 0 1 16.914 18.91c-16.073 4.389-33.972 8.114-53.204 11.112-5.548-17.208-12.243-33.305-20.02-47.941-6.579-12.382-13.758-23.391-21.43-32.969 28.802 11.207 55.23 28.377 77.74 50.888m-144.767 174.8h97.259c-1.004 28.268-4.686 55.49-10.81 80.612-27.194-3.381-56.349-5.43-86.449-6.006zm0-30.032v-76.041c30.005-.394 59.257-2.261 86.656-5.464 6.125 25.403 9.756 52.932 10.659 81.505zm-.002-208.845zc22.808 6.389 44.384 27.217 61.936 60.249 6.178 11.627 11.601 24.318 16.24 37.848-24.763 2.712-51.108 4.309-78.177 4.674zm.002 445.976V375.657c27.12.532 53.357 2.286 77.903 5.156-4.579 13.232-9.911 25.654-15.967 37.053-17.552 33.032-39.128 53.86-61.936 60.249m144.767-62.331c-23.051 23.051-50.21 40.496-79.821 51.678 8.457-10.156 16.34-22.011 23.51-35.504 7.62-14.341 14.198-30.088 19.68-46.906 19.465 3.213 37.473 7.186 53.515 11.859a230 230 0 0 1-16.884 18.873m34.823-44.775c-18.635-5.991-40-11.032-63.326-15.01 6.296-26.68 10.048-55.36 11.041-84.983h83.146c-2.328 35.678-12.918 69.753-30.861 99.993" />
+ </svg>
+);
+export default SvgWebsite;
diff --git a/src/components/svg/index.ts b/src/components/svg/index.ts
new file mode 100644
index 0000000..76756af
--- /dev/null
+++ b/src/components/svg/index.ts
@@ -0,0 +1,37 @@
+export { default as AddUser } from './AddUser';
+export { default as BarChart } from './BarChart';
+export { default as Bars } from './Bars';
+export { default as Bolt } from './Bolt';
+export { default as Bookmark } from './Bookmark';
+export { default as Change } from './Change';
+export { default as Compare } from './Compare';
+export { default as Dashboard } from './Dashboard';
+export { default as Download } from './Download';
+export { default as Expand } from './Expand';
+export { default as Export } from './Export';
+export { default as Flag } from './Flag';
+export { default as Funnel } from './Funnel';
+export { default as Gear } from './Gear';
+export { default as Lightbulb } from './Lightbulb';
+export { default as Lightning } from './Lightning';
+export { default as Location } from './Location';
+export { default as Lock } from './Lock';
+export { default as Logo } from './Logo';
+export { default as LogoWhite } from './LogoWhite';
+export { default as Magnet } from './Magnet';
+export { default as Money } from './Money';
+export { default as Network } from './Network';
+export { default as Nodes } from './Nodes';
+export { default as Overview } from './Overview';
+export { default as Path } from './Path';
+export { default as Profile } from './Profile';
+export { default as Pushpin } from './Pushpin';
+export { default as Redo } from './Redo';
+export { default as Reports } from './Reports';
+export { default as Security } from './Security';
+export { default as Speaker } from './Speaker';
+export { default as Switch } from './Switch';
+export { default as Tag } from './Tag';
+export { default as Target } from './Target';
+export { default as Visitor } from './Visitor';
+export { default as Website } from './Website';