aboutsummaryrefslogtreecommitdiff
path: root/src/components/input
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/input')
-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
28 files changed, 1787 insertions, 0 deletions
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>
+ );
+}