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