diff options
Diffstat (limited to 'src/components/input')
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} — {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> + ); +} |