From 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b Mon Sep 17 00:00:00 2001
From: Fuwn <50817549+Fuwn@users.noreply.github.com>
Date: Sat, 24 Jan 2026 13:09:50 +0000
Subject: Initial commit
Created from https://vercel.com/new
---
src/components/input/ActionSelect.tsx | 18 +++
src/components/input/CurrencySelect.tsx | 34 +++++
src/components/input/DateFilter.tsx | 141 ++++++++++++++++++++
src/components/input/DialogButton.tsx | 64 +++++++++
src/components/input/DownloadButton.tsx | 42 ++++++
src/components/input/ExportButton.tsx | 64 +++++++++
src/components/input/FieldFilters.tsx | 117 +++++++++++++++++
src/components/input/FilterBar.tsx | 155 ++++++++++++++++++++++
src/components/input/FilterButtons.tsx | 33 +++++
src/components/input/FilterEditForm.tsx | 95 ++++++++++++++
src/components/input/LanguageButton.tsx | 41 ++++++
src/components/input/LookupField.tsx | 65 +++++++++
src/components/input/MenuButton.tsx | 32 +++++
src/components/input/MobileMenuButton.tsx | 17 +++
src/components/input/MonthFilter.tsx | 18 +++
src/components/input/MonthSelect.tsx | 47 +++++++
src/components/input/NavButton.tsx | 188 +++++++++++++++++++++++++++
src/components/input/PanelButton.tsx | 19 +++
src/components/input/PreferencesButton.tsx | 32 +++++
src/components/input/ProfileButton.tsx | 74 +++++++++++
src/components/input/RefreshButton.tsx | 32 +++++
src/components/input/ReportEditButton.tsx | 99 ++++++++++++++
src/components/input/SegmentFilters.tsx | 42 ++++++
src/components/input/SegmentSaveButton.tsx | 26 ++++
src/components/input/SettingsButton.tsx | 84 ++++++++++++
src/components/input/WebsiteDateFilter.tsx | 102 +++++++++++++++
src/components/input/WebsiteFilterButton.tsx | 32 +++++
src/components/input/WebsiteSelect.tsx | 74 +++++++++++
28 files changed, 1787 insertions(+)
create mode 100644 src/components/input/ActionSelect.tsx
create mode 100644 src/components/input/CurrencySelect.tsx
create mode 100644 src/components/input/DateFilter.tsx
create mode 100644 src/components/input/DialogButton.tsx
create mode 100644 src/components/input/DownloadButton.tsx
create mode 100644 src/components/input/ExportButton.tsx
create mode 100644 src/components/input/FieldFilters.tsx
create mode 100644 src/components/input/FilterBar.tsx
create mode 100644 src/components/input/FilterButtons.tsx
create mode 100644 src/components/input/FilterEditForm.tsx
create mode 100644 src/components/input/LanguageButton.tsx
create mode 100644 src/components/input/LookupField.tsx
create mode 100644 src/components/input/MenuButton.tsx
create mode 100644 src/components/input/MobileMenuButton.tsx
create mode 100644 src/components/input/MonthFilter.tsx
create mode 100644 src/components/input/MonthSelect.tsx
create mode 100644 src/components/input/NavButton.tsx
create mode 100644 src/components/input/PanelButton.tsx
create mode 100644 src/components/input/PreferencesButton.tsx
create mode 100644 src/components/input/ProfileButton.tsx
create mode 100644 src/components/input/RefreshButton.tsx
create mode 100644 src/components/input/ReportEditButton.tsx
create mode 100644 src/components/input/SegmentFilters.tsx
create mode 100644 src/components/input/SegmentSaveButton.tsx
create mode 100644 src/components/input/SettingsButton.tsx
create mode 100644 src/components/input/WebsiteDateFilter.tsx
create mode 100644 src/components/input/WebsiteFilterButton.tsx
create mode 100644 src/components/input/WebsiteSelect.tsx
(limited to 'src/components/input')
diff --git a/src/components/input/ActionSelect.tsx b/src/components/input/ActionSelect.tsx
new file mode 100644
index 0000000..616ee34
--- /dev/null
+++ b/src/components/input/ActionSelect.tsx
@@ -0,0 +1,18 @@
+import { ListItem, Select } from '@umami/react-zen';
+import { useMessages } from '@/components/hooks';
+
+export interface ActionSelectProps {
+ value?: string;
+ onChange?: (value: string) => void;
+}
+
+export function ActionSelect({ value = 'path', onChange }: ActionSelectProps) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+
+ );
+}
diff --git a/src/components/input/CurrencySelect.tsx b/src/components/input/CurrencySelect.tsx
new file mode 100644
index 0000000..2b6045b
--- /dev/null
+++ b/src/components/input/CurrencySelect.tsx
@@ -0,0 +1,34 @@
+import { ListItem, Select } from '@umami/react-zen';
+import { useState } from 'react';
+import { useMessages } from '@/components/hooks';
+import { CURRENCIES } from '@/lib/constants';
+
+export function CurrencySelect({ value, onChange }) {
+ const { formatMessage, labels } = useMessages();
+ const [search, setSearch] = useState('');
+
+ return (
+
+ );
+}
diff --git a/src/components/input/DateFilter.tsx b/src/components/input/DateFilter.tsx
new file mode 100644
index 0000000..2e17529
--- /dev/null
+++ b/src/components/input/DateFilter.tsx
@@ -0,0 +1,141 @@
+import { Dialog, ListItem, ListSeparator, Modal, Select, type SelectProps } from '@umami/react-zen';
+import { endOfYear } from 'date-fns';
+import { Fragment, type Key, useState } from 'react';
+import { DateDisplay } from '@/components/common/DateDisplay';
+import { useMessages, useMobile } from '@/components/hooks';
+import { DatePickerForm } from '@/components/metrics/DatePickerForm';
+import { parseDateRange } from '@/lib/date';
+
+export interface DateFilterProps extends SelectProps {
+ value?: string;
+ onChange?: (value: string) => void;
+ showAllTime?: boolean;
+ renderDate?: boolean;
+ placement?: any;
+}
+
+export function DateFilter({
+ value,
+ onChange,
+ showAllTime,
+ renderDate,
+ placement = 'bottom',
+ ...props
+}: DateFilterProps) {
+ const { formatMessage, labels } = useMessages();
+ const [showPicker, setShowPicker] = useState(false);
+ const { startDate, endDate } = parseDateRange(value) || {};
+ const { isMobile } = useMobile();
+
+ const options = [
+ { label: formatMessage(labels.today), value: '0day' },
+ {
+ label: formatMessage(labels.lastHours, { x: '24' }),
+ value: '24hour',
+ },
+ {
+ label: formatMessage(labels.thisWeek),
+ value: '0week',
+ divider: true,
+ },
+ {
+ label: formatMessage(labels.lastDays, { x: '7' }),
+ value: '7day',
+ },
+ {
+ label: formatMessage(labels.thisMonth),
+ value: '0month',
+ divider: true,
+ },
+ {
+ label: formatMessage(labels.lastDays, { x: '30' }),
+ value: '30day',
+ },
+ {
+ label: formatMessage(labels.lastDays, { x: '90' }),
+ value: '90day',
+ },
+ { label: formatMessage(labels.thisYear), value: '0year' },
+ {
+ label: formatMessage(labels.lastMonths, { x: '6' }),
+ value: '6month',
+ divider: true,
+ },
+ {
+ label: formatMessage(labels.lastMonths, { x: '12' }),
+ value: '12month',
+ },
+ showAllTime && {
+ label: formatMessage(labels.allTime),
+ value: 'all',
+ divider: true,
+ },
+ {
+ label: formatMessage(labels.customRange),
+ value: 'custom',
+ divider: true,
+ },
+ ]
+ .filter(n => n)
+ .map((a, id) => ({ ...a, id }));
+
+ const handleChange = (value: Key) => {
+ if (value === 'custom') {
+ setShowPicker(true);
+ return;
+ }
+ onChange(value.toString());
+ };
+
+ const handlePickerChange = (value: string) => {
+ setShowPicker(false);
+ onChange(value.toString());
+ };
+
+ const renderValue = ({ defaultChildren }) => {
+ return value?.startsWith('range') || renderDate ? (
+
+ ) : (
+ defaultChildren
+ );
+ };
+
+ const selectedValue = value.endsWith(':all') ? 'all' : value;
+
+ return (
+ <>
+
+ {showPicker && (
+
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/input/DialogButton.tsx b/src/components/input/DialogButton.tsx
new file mode 100644
index 0000000..7527226
--- /dev/null
+++ b/src/components/input/DialogButton.tsx
@@ -0,0 +1,64 @@
+import {
+ Button,
+ type ButtonProps,
+ Dialog,
+ type DialogProps,
+ DialogTrigger,
+ IconLabel,
+ Modal,
+} from '@umami/react-zen';
+import type { CSSProperties, ReactNode } from 'react';
+import { useMobile } from '@/components/hooks';
+
+export interface DialogButtonProps extends Omit {
+ icon?: ReactNode;
+ label?: ReactNode;
+ title?: ReactNode;
+ width?: string;
+ height?: string;
+ minWidth?: string;
+ minHeight?: string;
+ children?: DialogProps['children'];
+}
+
+export function DialogButton({
+ icon,
+ label,
+ title,
+ width,
+ height,
+ minWidth,
+ minHeight,
+ children,
+ ...props
+}: DialogButtonProps) {
+ const { isMobile } = useMobile();
+ const style: CSSProperties = {
+ width,
+ height,
+ minWidth,
+ minHeight,
+ maxHeight: 'calc(100dvh - 40px)',
+ padding: '32px',
+ };
+
+ if (isMobile) {
+ style.width = '100%';
+ style.height = '100%';
+ style.maxHeight = '100%';
+ style.overflowY = 'auto';
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/input/DownloadButton.tsx b/src/components/input/DownloadButton.tsx
new file mode 100644
index 0000000..5df3305
--- /dev/null
+++ b/src/components/input/DownloadButton.tsx
@@ -0,0 +1,42 @@
+import { Button, Icon, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import Papa from 'papaparse';
+import { useMessages } from '@/components/hooks';
+import { Download } from '@/components/icons';
+
+export function DownloadButton({
+ filename = 'data',
+ data,
+}: {
+ filename?: string;
+ data?: any;
+ onClick?: () => void;
+}) {
+ const { formatMessage, labels } = useMessages();
+
+ const handleClick = async () => {
+ downloadCsv(`${filename}.csv`, Papa.unparse(data));
+ };
+
+ return (
+
+
+ {formatMessage(labels.download)}
+
+ );
+}
+
+function downloadCsv(filename: string, data: any) {
+ const blob = new Blob([data], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+
+ URL.revokeObjectURL(url);
+}
diff --git a/src/components/input/ExportButton.tsx b/src/components/input/ExportButton.tsx
new file mode 100644
index 0000000..7b65a57
--- /dev/null
+++ b/src/components/input/ExportButton.tsx
@@ -0,0 +1,64 @@
+import { Icon, LoadingButton, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import { useSearchParams } from 'next/navigation';
+import { useState } from 'react';
+import { useApi, useMessages } from '@/components/hooks';
+import { useDateParameters } from '@/components/hooks/useDateParameters';
+import { useFilterParameters } from '@/components/hooks/useFilterParameters';
+import { Download } from '@/components/icons';
+
+export function ExportButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const [isLoading, setIsLoading] = useState(false);
+ const date = useDateParameters();
+ const filters = useFilterParameters();
+ const searchParams = useSearchParams();
+ const { get } = useApi();
+
+ const handleClick = async () => {
+ setIsLoading(true);
+
+ const { zip } = await get(`/websites/${websiteId}/export`, {
+ ...date,
+ ...filters,
+ ...searchParams,
+ format: 'json',
+ });
+
+ await loadZip(zip);
+
+ setIsLoading(false);
+ };
+
+ return (
+
+
+
+
+
+
+ {formatMessage(labels.download)}
+
+ );
+}
+
+async function loadZip(zip: string) {
+ const binary = atob(zip);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+
+ const blob = new Blob([bytes], { type: 'application/zip' });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'download.zip';
+ a.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/src/components/input/FieldFilters.tsx b/src/components/input/FieldFilters.tsx
new file mode 100644
index 0000000..2174068
--- /dev/null
+++ b/src/components/input/FieldFilters.tsx
@@ -0,0 +1,117 @@
+import {
+ Button,
+ Column,
+ Grid,
+ Icon,
+ List,
+ ListItem,
+ Menu,
+ MenuItem,
+ MenuTrigger,
+ Popover,
+ Row,
+} from '@umami/react-zen';
+import { endOfDay, subMonths } from 'date-fns';
+import type { Key } from 'react';
+import { Empty } from '@/components/common/Empty';
+import { FilterRecord } from '@/components/common/FilterRecord';
+import { useFields, useMessages, useMobile } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+
+export interface FieldFiltersProps {
+ websiteId: string;
+ value?: { name: string; operator: string; value: string }[];
+ exclude?: string[];
+ onChange?: (data: any) => void;
+}
+
+export function FieldFilters({ websiteId, value, exclude = [], onChange }: FieldFiltersProps) {
+ const { formatMessage, messages } = useMessages();
+ const { fields } = useFields();
+ const startDate = subMonths(endOfDay(new Date()), 6);
+ const endDate = endOfDay(new Date());
+ const { isMobile } = useMobile();
+
+ const updateFilter = (name: string, props: Record) => {
+ onChange(value.map(filter => (filter.name === name ? { ...filter, ...props } : filter)));
+ };
+
+ const handleAdd = (name: Key) => {
+ onChange(value.concat({ name: name.toString(), operator: 'eq', value: '' }));
+ };
+
+ const handleChange = (name: string, value: Key) => {
+ updateFilter(name, { value });
+ };
+
+ const handleSelect = (name: string, operator: Key) => {
+ updateFilter(name, { operator });
+ };
+
+ const handleRemove = (name: string) => {
+ onChange(value.filter(filter => filter.name !== name));
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {fields
+ .filter(({ name }) => !exclude.includes(name))
+ .map(field => {
+ const isDisabled = !!value.find(({ name }) => name === field.name);
+ return (
+
+ {field.label}
+
+ );
+ })}
+
+
+
+ {value.map(filter => {
+ return (
+
+ );
+ })}
+ {!value.length && }
+
+
+ );
+}
diff --git a/src/components/input/FilterBar.tsx b/src/components/input/FilterBar.tsx
new file mode 100644
index 0000000..5a52e56
--- /dev/null
+++ b/src/components/input/FilterBar.tsx
@@ -0,0 +1,155 @@
+import {
+ Button,
+ Dialog,
+ DialogTrigger,
+ Icon,
+ Modal,
+ Row,
+ Text,
+ Tooltip,
+ TooltipTrigger,
+} from '@umami/react-zen';
+import { SegmentEditForm } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditForm';
+import {
+ useFilters,
+ useFormat,
+ useMessages,
+ useNavigation,
+ useWebsiteSegmentQuery,
+} from '@/components/hooks';
+import { Bookmark, X } from '@/components/icons';
+import { isSearchOperator } from '@/lib/params';
+
+export function FilterBar({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+ const { formatValue } = useFormat();
+ const {
+ router,
+ pathname,
+ updateParams,
+ replaceParams,
+ query: { segment, cohort },
+ } = useNavigation();
+ const { filters, operatorLabels } = useFilters();
+ const { data, isLoading } = useWebsiteSegmentQuery(websiteId, segment || cohort);
+ const canSaveSegment = filters.length > 0 && !segment && !cohort && !pathname.includes('/share');
+
+ const handleCloseFilter = (param: string) => {
+ router.push(updateParams({ [param]: undefined }));
+ };
+
+ const handleResetFilter = () => {
+ router.push(replaceParams());
+ };
+
+ const handleSegmentRemove = (type: string) => {
+ router.push(updateParams({ [type]: undefined }));
+ };
+
+ if (!filters.length && !segment && !cohort) {
+ return null;
+ }
+
+ return (
+
+
+ {segment && !isLoading && (
+ handleSegmentRemove('segment')}
+ />
+ )}
+ {cohort && !isLoading && (
+ handleSegmentRemove('cohort')}
+ />
+ )}
+ {filters.map(filter => {
+ const { name, label, operator, value } = filter;
+ const paramValue = isSearchOperator(operator) ? value : formatValue(value, name);
+
+ return (
+ handleCloseFilter(name)}
+ />
+ );
+ })}
+
+
+
+ {canSaveSegment && (
+
+
+
+ {formatMessage(labels.saveSegment)}
+
+
+ )}
+
+
+
+
+
+
+
+ {formatMessage(labels.clearAll)}
+
+
+
+
+ );
+}
+
+const FilterItem = ({ name, label, operator, value, onRemove }) => {
+ return (
+
+
+
+
+ {label}
+
+ {operator}
+
+ {value}
+
+
+ onRemove(name)} size="xs" style={{ cursor: 'pointer' }}>
+
+
+
+
+ );
+};
diff --git a/src/components/input/FilterButtons.tsx b/src/components/input/FilterButtons.tsx
new file mode 100644
index 0000000..ff37fb1
--- /dev/null
+++ b/src/components/input/FilterButtons.tsx
@@ -0,0 +1,33 @@
+import { Box, ToggleGroup, ToggleGroupItem } from '@umami/react-zen';
+import { useState } from 'react';
+
+export interface FilterButtonsProps {
+ items: { id: string; label: string }[];
+ value: string;
+ onChange?: (value: string) => void;
+}
+
+export function FilterButtons({ items, value, onChange }: FilterButtonsProps) {
+ const [selected, setSelected] = useState(value);
+
+ const handleChange = (value: string) => {
+ setSelected(value);
+ onChange?.(value);
+ };
+
+ return (
+
+ handleChange(e[0])}
+ disallowEmptySelection={true}
+ >
+ {items.map(({ id, label }) => (
+
+ {label}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/input/FilterEditForm.tsx b/src/components/input/FilterEditForm.tsx
new file mode 100644
index 0000000..44f4384
--- /dev/null
+++ b/src/components/input/FilterEditForm.tsx
@@ -0,0 +1,95 @@
+import { Button, Column, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
+import { useState } from 'react';
+import { useFilters, useMessages, useMobile, useNavigation } from '@/components/hooks';
+import { FieldFilters } from '@/components/input/FieldFilters';
+import { SegmentFilters } from '@/components/input/SegmentFilters';
+
+export interface FilterEditFormProps {
+ websiteId?: string;
+ onChange?: (params: { filters: any[]; segment?: string; cohort?: string }) => void;
+ onClose?: () => void;
+}
+
+export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormProps) {
+ const {
+ query: { segment, cohort },
+ pathname,
+ } = useNavigation();
+ const { filters } = useFilters();
+ const { formatMessage, labels } = useMessages();
+ const [currentFilters, setCurrentFilters] = useState(filters);
+ const [currentSegment, setCurrentSegment] = useState(segment);
+ const [currentCohort, setCurrentCohort] = useState(cohort);
+ const { isMobile } = useMobile();
+ const excludeFilters = pathname.includes('/pixels') || pathname.includes('/links');
+
+ const handleReset = () => {
+ setCurrentFilters([]);
+ setCurrentSegment(undefined);
+ setCurrentCohort(undefined);
+ };
+
+ const handleSave = () => {
+ onChange?.({
+ filters: currentFilters.filter(f => f.value),
+ segment: currentSegment,
+ cohort: currentCohort,
+ });
+ onClose?.();
+ };
+
+ const handleSegmentChange = (id: string, type: string) => {
+ setCurrentSegment(type === 'segment' ? id : undefined);
+ setCurrentCohort(type === 'cohort' ? id : undefined);
+ };
+
+ return (
+
+
+
+
+ {formatMessage(labels.fields)}
+ {!excludeFilters && (
+ <>
+ {formatMessage(labels.segments)}
+ {formatMessage(labels.cohorts)}
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/input/LanguageButton.tsx b/src/components/input/LanguageButton.tsx
new file mode 100644
index 0000000..ac43dcb
--- /dev/null
+++ b/src/components/input/LanguageButton.tsx
@@ -0,0 +1,41 @@
+import { Button, Dialog, Grid, Icon, MenuTrigger, Popover, Text } from '@umami/react-zen';
+import { Globe } from 'lucide-react';
+import { useLocale } from '@/components/hooks';
+import { languages } from '@/lib/lang';
+
+export function LanguageButton() {
+ const { locale, saveLocale } = useLocale();
+ const items = Object.keys(languages).map(key => ({ ...languages[key], value: key }));
+
+ function handleSelect(value: string) {
+ saveLocale(value);
+ }
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/input/LookupField.tsx b/src/components/input/LookupField.tsx
new file mode 100644
index 0000000..c1d419f
--- /dev/null
+++ b/src/components/input/LookupField.tsx
@@ -0,0 +1,65 @@
+import { ComboBox, type ComboBoxProps, ListItem, Loading, useDebounce } from '@umami/react-zen';
+import { endOfDay, subMonths } from 'date-fns';
+import { type SetStateAction, useMemo, useState } from 'react';
+import { Empty } from '@/components/common/Empty';
+import { useMessages, useWebsiteValuesQuery } from '@/components/hooks';
+
+export interface LookupFieldProps extends ComboBoxProps {
+ websiteId: string;
+ type: string;
+ value: string;
+ onChange: (value: string) => void;
+}
+
+export function LookupField({ websiteId, type, value, onChange, ...props }: LookupFieldProps) {
+ const { formatMessage, messages } = useMessages();
+ const [search, setSearch] = useState(value);
+ const searchValue = useDebounce(search, 300);
+ const startDate = subMonths(endOfDay(new Date()), 6);
+ const endDate = endOfDay(new Date());
+
+ const { data, isLoading } = useWebsiteValuesQuery({
+ websiteId,
+ type,
+ search: searchValue,
+ startDate,
+ endDate,
+ });
+
+ const items: string[] = useMemo(() => {
+ return data?.map(({ value }) => value) || [];
+ }, [data]);
+
+ const handleSearch = (value: SetStateAction) => {
+ setSearch(value);
+ };
+
+ return (
+ {
+ handleSearch(value);
+ onChange?.(value);
+ }}
+ formValue="text"
+ allowsEmptyCollection
+ allowsCustomValue
+ renderEmptyState={() =>
+ isLoading ? (
+
+ ) : (
+
+ )
+ }
+ >
+ {items.map(item => (
+
+ {item}
+
+ ))}
+
+ );
+}
diff --git a/src/components/input/MenuButton.tsx b/src/components/input/MenuButton.tsx
new file mode 100644
index 0000000..bac307f
--- /dev/null
+++ b/src/components/input/MenuButton.tsx
@@ -0,0 +1,32 @@
+import { Button, DialogTrigger, Icon, Menu, Popover } from '@umami/react-zen';
+import type { Key, ReactNode } from 'react';
+import { Ellipsis } from '@/components/icons';
+
+export function MenuButton({
+ children,
+ onAction,
+ isDisabled,
+}: {
+ children: ReactNode;
+ onAction?: (action: string) => void;
+ isDisabled?: boolean;
+}) {
+ const handleAction = (key: Key) => {
+ onAction?.(key.toString());
+ };
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/input/MobileMenuButton.tsx b/src/components/input/MobileMenuButton.tsx
new file mode 100644
index 0000000..5e59cbb
--- /dev/null
+++ b/src/components/input/MobileMenuButton.tsx
@@ -0,0 +1,17 @@
+import { Button, Dialog, type DialogProps, DialogTrigger, Icon, Modal } from '@umami/react-zen';
+import { Menu } from '@/components/icons';
+
+export function MobileMenuButton(props: DialogProps) {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/input/MonthFilter.tsx b/src/components/input/MonthFilter.tsx
new file mode 100644
index 0000000..dec64b0
--- /dev/null
+++ b/src/components/input/MonthFilter.tsx
@@ -0,0 +1,18 @@
+import { useDateRange, useNavigation } from '@/components/hooks';
+import { getMonthDateRangeValue } from '@/lib/date';
+import { MonthSelect } from './MonthSelect';
+
+export function MonthFilter() {
+ const { router, updateParams } = useNavigation();
+ const {
+ dateRange: { startDate },
+ } = useDateRange();
+
+ const handleMonthSelect = (date: Date) => {
+ const range = getMonthDateRangeValue(date);
+
+ router.push(updateParams({ date: range, offset: undefined }));
+ };
+
+ return ;
+}
diff --git a/src/components/input/MonthSelect.tsx b/src/components/input/MonthSelect.tsx
new file mode 100644
index 0000000..241634e
--- /dev/null
+++ b/src/components/input/MonthSelect.tsx
@@ -0,0 +1,47 @@
+import { ListItem, Row, Select } from '@umami/react-zen';
+import { useLocale } from '@/components/hooks';
+import { formatDate } from '@/lib/date';
+
+export function MonthSelect({ date = new Date(), onChange }) {
+ const { locale } = useLocale();
+ const month = date.getMonth();
+ const year = date.getFullYear();
+ const currentYear = new Date().getFullYear();
+
+ const months = [...Array(12)].map((_, i) => i);
+ const years = [...Array(10)].map((_, i) => currentYear - i);
+
+ const handleMonthChange = (month: number) => {
+ const d = new Date(date);
+ d.setMonth(month);
+ onChange?.(d);
+ };
+ const handleYearChange = (year: number) => {
+ const d = new Date(date);
+ d.setFullYear(year);
+ onChange?.(d);
+ };
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/input/NavButton.tsx b/src/components/input/NavButton.tsx
new file mode 100644
index 0000000..ab77ef0
--- /dev/null
+++ b/src/components/input/NavButton.tsx
@@ -0,0 +1,188 @@
+import {
+ Column,
+ Icon,
+ IconLabel,
+ Menu,
+ MenuItem,
+ MenuSection,
+ MenuSeparator,
+ MenuTrigger,
+ Popover,
+ Pressable,
+ Row,
+ SubmenuTrigger,
+ Text,
+} from '@umami/react-zen';
+import { ArrowRight } from 'lucide-react';
+import type { Key } from 'react';
+import {
+ useConfig,
+ useLoginQuery,
+ useMessages,
+ useMobile,
+ useNavigation,
+} from '@/components/hooks';
+import {
+ BookText,
+ ChevronRight,
+ ExternalLink,
+ LifeBuoy,
+ LockKeyhole,
+ LogOut,
+ Settings,
+ User,
+ Users,
+} from '@/components/icons';
+import { Switch } from '@/components/svg';
+import { DOCS_URL, LAST_TEAM_CONFIG } from '@/lib/constants';
+import { removeItem } from '@/lib/storage';
+
+export interface TeamsButtonProps {
+ showText?: boolean;
+ onAction?: (id: any) => void;
+}
+
+export function NavButton({ showText = true }: TeamsButtonProps) {
+ const { user } = useLoginQuery();
+ const { cloudMode } = useConfig();
+ const { formatMessage, labels } = useMessages();
+ const { teamId, router } = useNavigation();
+ const { isMobile } = useMobile();
+ const team = user?.teams?.find(({ id }) => id === teamId);
+ const selectedKeys = new Set([teamId || 'user']);
+ const label = teamId ? team?.name : user.username;
+
+ const getUrl = (url: string) => {
+ return cloudMode ? `${process.env.cloudUrl}${url}` : url;
+ };
+
+ const handleAction = async (key: Key) => {
+ if (key === 'user') {
+ removeItem(LAST_TEAM_CONFIG);
+ if (cloudMode) {
+ window.location.href = '/';
+ } else {
+ router.push('/');
+ }
+ }
+ };
+
+ return (
+
+
+
+
+ {teamId ? : }
+ {showText && {label}}
+
+ {showText && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/input/PanelButton.tsx b/src/components/input/PanelButton.tsx
new file mode 100644
index 0000000..500c40c
--- /dev/null
+++ b/src/components/input/PanelButton.tsx
@@ -0,0 +1,19 @@
+import { Button, type ButtonProps, Icon } from '@umami/react-zen';
+import { useGlobalState } from '@/components/hooks';
+import { PanelLeft } from '@/components/icons';
+
+export function PanelButton(props: ButtonProps) {
+ const [isCollapsed, setIsCollapsed] = useGlobalState('sidenav-collapsed');
+ return (
+
+ );
+}
diff --git a/src/components/input/PreferencesButton.tsx b/src/components/input/PreferencesButton.tsx
new file mode 100644
index 0000000..710a7fa
--- /dev/null
+++ b/src/components/input/PreferencesButton.tsx
@@ -0,0 +1,32 @@
+import { Button, Column, DialogTrigger, Icon, Label, Popover } from '@umami/react-zen';
+import { DateRangeSetting } from '@/app/(main)/settings/preferences/DateRangeSetting';
+import { TimezoneSetting } from '@/app/(main)/settings/preferences/TimezoneSetting';
+import { Panel } from '@/components/common/Panel';
+import { useMessages } from '@/components/hooks';
+import { Settings } from '@/components/icons';
+
+export function PreferencesButton() {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/input/ProfileButton.tsx b/src/components/input/ProfileButton.tsx
new file mode 100644
index 0000000..505cd88
--- /dev/null
+++ b/src/components/input/ProfileButton.tsx
@@ -0,0 +1,74 @@
+import {
+ Button,
+ Icon,
+ Menu,
+ MenuItem,
+ MenuSection,
+ MenuSeparator,
+ MenuTrigger,
+ Popover,
+ Row,
+ Text,
+} from '@umami/react-zen';
+import { Fragment } from 'react';
+import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
+import { LockKeyhole, LogOut, UserCircle } from '@/components/icons';
+
+export function ProfileButton() {
+ const { formatMessage, labels } = useMessages();
+ const { user } = useLoginQuery();
+ const { renderUrl } = useNavigation();
+
+ const items = [
+ {
+ id: 'settings',
+ label: formatMessage(labels.profile),
+ path: renderUrl('/settings/profile'),
+ icon: ,
+ },
+ user.isAdmin &&
+ !process.env.cloudMode && {
+ id: 'admin',
+ label: formatMessage(labels.admin),
+ path: '/admin',
+ icon: ,
+ },
+ {
+ id: 'logout',
+ label: formatMessage(labels.logout),
+ path: '/logout',
+ icon: ,
+ separator: true,
+ },
+ ].filter(n => n);
+
+ return (
+
+
+
+
+
+
+ {items.map(({ id, path, label, icon, separator }) => {
+ return (
+
+ {separator && }
+
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/src/components/input/RefreshButton.tsx b/src/components/input/RefreshButton.tsx
new file mode 100644
index 0000000..b52f830
--- /dev/null
+++ b/src/components/input/RefreshButton.tsx
@@ -0,0 +1,32 @@
+import { Icon, LoadingButton, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import { useDateRange, useMessages } from '@/components/hooks';
+import { RefreshCw } from '@/components/icons';
+import { setWebsiteDateRange } from '@/store/websites';
+
+export function RefreshButton({
+ websiteId,
+ isLoading,
+}: {
+ websiteId: string;
+ isLoading?: boolean;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { dateRange } = useDateRange();
+
+ function handleClick() {
+ if (!isLoading && dateRange) {
+ setWebsiteDateRange(websiteId, dateRange);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+ {formatMessage(labels.refresh)}
+
+ );
+}
diff --git a/src/components/input/ReportEditButton.tsx b/src/components/input/ReportEditButton.tsx
new file mode 100644
index 0000000..b333077
--- /dev/null
+++ b/src/components/input/ReportEditButton.tsx
@@ -0,0 +1,99 @@
+import {
+ AlertDialog,
+ Button,
+ Icon,
+ Menu,
+ MenuItem,
+ MenuTrigger,
+ Modal,
+ Popover,
+ Row,
+ Text,
+} from '@umami/react-zen';
+import { type ReactNode, useState } from 'react';
+import { useMessages } from '@/components/hooks';
+import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery';
+import { Edit, MoreHorizontal, Trash } from '@/components/icons';
+
+export function ReportEditButton({
+ id,
+ name,
+ type,
+ children,
+ onDelete,
+}: {
+ id: string;
+ name: string;
+ type: string;
+ onDelete?: () => void;
+ children: ({ close }: { close: () => void }) => ReactNode;
+}) {
+ const { formatMessage, labels, messages } = useMessages();
+ const [showEdit, setShowEdit] = useState(false);
+ const [showDelete, setShowDelete] = useState(false);
+ const { mutateAsync, touch } = useDeleteQuery(`/reports/${id}`);
+
+ const handleAction = (id: any) => {
+ if (id === 'edit') {
+ setShowEdit(true);
+ } else if (id === 'delete') {
+ setShowDelete(true);
+ }
+ };
+
+ const handleClose = () => {
+ setShowEdit(false);
+ setShowDelete(false);
+ };
+
+ const handleDelete = async () => {
+ await mutateAsync(null, {
+ onSuccess: async () => {
+ touch(`reports:${type}`);
+ setShowDelete(false);
+ onDelete?.();
+ },
+ });
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {showEdit && children({ close: handleClose })}
+ {showDelete && (
+
+ {formatMessage(messages.confirmDelete, { target: name })}
+
+ )}
+
+ >
+ );
+}
diff --git a/src/components/input/SegmentFilters.tsx b/src/components/input/SegmentFilters.tsx
new file mode 100644
index 0000000..f03a1de
--- /dev/null
+++ b/src/components/input/SegmentFilters.tsx
@@ -0,0 +1,42 @@
+import { IconLabel, List, ListItem } from '@umami/react-zen';
+import { Empty } from '@/components/common/Empty';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useWebsiteSegmentsQuery } from '@/components/hooks';
+import { ChartPie, UserPlus } from '@/components/icons';
+
+export interface SegmentFiltersProps {
+ websiteId: string;
+ segmentId: string;
+ type?: string;
+ onChange?: (id: string, type: string) => void;
+}
+
+export function SegmentFilters({
+ websiteId,
+ segmentId,
+ type = 'segment',
+ onChange,
+}: SegmentFiltersProps) {
+ const { data, isLoading, isFetching } = useWebsiteSegmentsQuery(websiteId, { type });
+
+ const handleChange = (id: string) => {
+ onChange?.(id, type);
+ };
+
+ return (
+
+ {data?.data?.length === 0 && }
+ handleChange(id[0])}>
+ {data?.data?.map(item => {
+ return (
+
+ : }>
+ {item.name}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/components/input/SegmentSaveButton.tsx b/src/components/input/SegmentSaveButton.tsx
new file mode 100644
index 0000000..5f6cac1
--- /dev/null
+++ b/src/components/input/SegmentSaveButton.tsx
@@ -0,0 +1,26 @@
+import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen';
+import { SegmentEditForm } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditForm';
+import { useMessages } from '@/components/hooks';
+import { Plus } from '@/components/icons';
+
+export function SegmentSaveButton({ websiteId }: { websiteId: string }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/input/SettingsButton.tsx b/src/components/input/SettingsButton.tsx
new file mode 100644
index 0000000..bd51fb5
--- /dev/null
+++ b/src/components/input/SettingsButton.tsx
@@ -0,0 +1,84 @@
+import {
+ Button,
+ Icon,
+ Menu,
+ MenuItem,
+ MenuSection,
+ MenuSeparator,
+ MenuTrigger,
+ Popover,
+} from '@umami/react-zen';
+import type { Key } from 'react';
+import { useConfig, useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
+import {
+ BookText,
+ ExternalLink,
+ LifeBuoy,
+ LockKeyhole,
+ LogOut,
+ Settings,
+ UserCircle,
+} from '@/components/icons';
+import { DOCS_URL } from '@/lib/constants';
+
+export function SettingsButton() {
+ const { formatMessage, labels } = useMessages();
+ const { user } = useLoginQuery();
+ const { router } = useNavigation();
+ const { cloudMode } = useConfig();
+
+ const handleAction = (id: Key) => {
+ const url = id.toString();
+
+ if (cloudMode) {
+ if (url === '/docs') {
+ window.open(DOCS_URL, '_blank');
+ } else {
+ window.location.href = url;
+ }
+ } else {
+ router.push(url);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ } label={formatMessage(labels.settings)} />
+ {!cloudMode && user.isAdmin && (
+ } label={formatMessage(labels.admin)} />
+ )}
+ {cloudMode && (
+ <>
+ }
+ label={formatMessage(labels.documentation)}
+ >
+
+
+
+
+ }
+ label={formatMessage(labels.support)}
+ />
+ >
+ )}
+
+ } label={formatMessage(labels.logout)} />
+
+
+
+
+ );
+}
diff --git a/src/components/input/WebsiteDateFilter.tsx b/src/components/input/WebsiteDateFilter.tsx
new file mode 100644
index 0000000..18b4f13
--- /dev/null
+++ b/src/components/input/WebsiteDateFilter.tsx
@@ -0,0 +1,102 @@
+import { Button, Icon, ListItem, Row, Select, Text } from '@umami/react-zen';
+import { isAfter } from 'date-fns';
+import { useMemo } from 'react';
+import { useDateRange, useDateRangeQuery, useMessages, useNavigation } from '@/components/hooks';
+import { ChevronRight } from '@/components/icons';
+import { getDateRangeValue } from '@/lib/date';
+import { DateFilter } from './DateFilter';
+
+export interface WebsiteDateFilterProps {
+ websiteId: string;
+ compare?: string;
+ showAllTime?: boolean;
+ showButtons?: boolean;
+ allowCompare?: boolean;
+}
+
+export function WebsiteDateFilter({
+ websiteId,
+ showAllTime = true,
+ showButtons = true,
+ allowCompare,
+}: WebsiteDateFilterProps) {
+ const { dateRange, isAllTime, isCustomRange } = useDateRange();
+ const { formatMessage, labels } = useMessages();
+ const {
+ router,
+ updateParams,
+ query: { compare = 'prev', offset = 0 },
+ } = useNavigation();
+ const disableForward = isAllTime || isAfter(dateRange.endDate, new Date());
+ const showCompare = allowCompare && !isAllTime;
+
+ const websiteDateRange = useDateRangeQuery(websiteId);
+
+ const handleChange = (date: string) => {
+ if (date === 'all') {
+ router.push(
+ updateParams({
+ date: `${getDateRangeValue(websiteDateRange.startDate, websiteDateRange.endDate)}:all`,
+ offset: undefined,
+ }),
+ );
+ } else {
+ router.push(updateParams({ date, offset: undefined }));
+ }
+ };
+
+ const handleIncrement = increment => {
+ router.push(updateParams({ offset: Number(offset) + increment }));
+ };
+ const handleSelect = (compare: any) => {
+ router.push(updateParams({ compare }));
+ };
+
+ const dateValue = useMemo(() => {
+ return offset !== 0
+ ? getDateRangeValue(dateRange.startDate, dateRange.endDate)
+ : dateRange.value;
+ }, [dateRange]);
+
+ return (
+
+ {showButtons && !isAllTime && !isCustomRange && (
+
+
+
+
+ )}
+
+
+
+ {showCompare && (
+
+ VS
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/input/WebsiteFilterButton.tsx b/src/components/input/WebsiteFilterButton.tsx
new file mode 100644
index 0000000..7db850a
--- /dev/null
+++ b/src/components/input/WebsiteFilterButton.tsx
@@ -0,0 +1,32 @@
+import { useMessages, useNavigation } from '@/components/hooks';
+import { ListFilter } from '@/components/icons';
+import { DialogButton } from '@/components/input/DialogButton';
+import { FilterEditForm } from '@/components/input/FilterEditForm';
+import { filtersArrayToObject } from '@/lib/params';
+
+export function WebsiteFilterButton({
+ websiteId,
+}: {
+ websiteId: string;
+ position?: 'bottom' | 'top' | 'left' | 'right';
+ alignment?: 'end' | 'center' | 'start';
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { updateParams, router } = useNavigation();
+
+ const handleChange = ({ filters, segment, cohort }: any) => {
+ const params = filtersArrayToObject(filters);
+
+ const url = updateParams({ ...params, segment, cohort });
+
+ router.push(url);
+ };
+
+ return (
+ } label={formatMessage(labels.filter)} variant="outline">
+ {({ close }) => {
+ return ;
+ }}
+
+ );
+}
diff --git a/src/components/input/WebsiteSelect.tsx b/src/components/input/WebsiteSelect.tsx
new file mode 100644
index 0000000..8d81eb9
--- /dev/null
+++ b/src/components/input/WebsiteSelect.tsx
@@ -0,0 +1,74 @@
+import { ListItem, Row, Select, type SelectProps, Text } from '@umami/react-zen';
+import { useState } from 'react';
+import { Empty } from '@/components/common/Empty';
+import {
+ useLoginQuery,
+ useMessages,
+ useUserWebsitesQuery,
+ useWebsiteQuery,
+} from '@/components/hooks';
+
+export function WebsiteSelect({
+ websiteId,
+ teamId,
+ onChange,
+ includeTeams,
+ ...props
+}: {
+ websiteId?: string;
+ teamId?: string;
+ includeTeams?: boolean;
+} & SelectProps) {
+ const { formatMessage, messages } = useMessages();
+ const { data: website } = useWebsiteQuery(websiteId);
+ const [name, setName] = useState(website?.name);
+ const [search, setSearch] = useState('');
+ const { user } = useLoginQuery();
+ const { data, isLoading } = useUserWebsitesQuery(
+ { userId: user?.id, teamId },
+ { search, pageSize: 10, includeTeams },
+ );
+ const listItems: { id: string; name: string }[] = data?.data || [];
+
+ const handleSearch = (value: string) => {
+ setSearch(value);
+ };
+
+ const handleOpenChange = () => {
+ setSearch('');
+ };
+
+ const handleChange = (id: string) => {
+ setName(listItems.find(item => item.id === id)?.name);
+ onChange(id);
+ };
+
+ const renderValue = () => {
+ return (
+
+ {name}
+
+ );
+ };
+
+ return (
+
+ );
+}
--
cgit v1.2.3