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