{
+ 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 (
+ setShowLink(true)}
+ onMouseOut={() => setShowLink(false)}
+ >
+ {icon}
+ {!value && `(${label || formatMessage(labels.unknown)})`}
+ {value && (
+
+
+ {label || value}
+
+
+ )}
+ {externalUrl && showLink && (
+
+
+
+
+
+ )}
+
+ );
+}
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 (
+
+
+
+
+
+ {isSearch && (
+
+ )}
+ {!isSearch && (
+
+ )}
+
+
+
+
+
+
+ );
+}
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 (
+
+ {children}
+
+ );
+}
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 (
+
+ );
+}
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 = () => ,
+ children,
+ ...props
+}: LoadingPanelProps): ReactNode {
+ const empty = isEmpty ?? checkEmpty(data);
+
+ // Show loading spinner only if no data exists
+ if (isLoading || isFetching) {
+ return (
+
+
+
+ );
+ }
+
+ // Show error
+ if (error) {
+ return ;
+ }
+
+ // 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 ;
+ }
+
+ if (isLoading) {
+ return ;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
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 (
+
+
+ {label}
+
+ {icon && (
+
+ {icon}
+
+ )}
+ {title && titleHref ? (
+
+ {title}
+
+ ) : (
+ title && {title}
+ )}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+ {children}
+
+
+ );
+}
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 (
+
+ {formatMessage(labels.numberOfRecords, { x: count.toLocaleString() })}
+
+
+ {formatMessage(labels.pageOf, {
+ current: page.toLocaleString(),
+ total: maxPage.toLocaleString(),
+ })}
+
+
+
+
+
+
+
+ );
+}
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 (
+
+ {title && {title}}
+ {allowFullscreen && (
+
+
+
+ {formatMessage(labels.maximize)}
+
+
+ )}
+ {children}
+
+ );
+}
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 (
+
+
+ {icon && {icon}}
+ {title && {title}}
+ {description && {description}}
+
+ {children}
+
+ );
+}
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 (
+
+
+ {label}
+
+
+ );
+ });
+ };
+
+ return (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {items?.map(({ label, items }, index) => {
+ if (label) {
+ return (
+
+ {renderItems(items)}
+
+ );
+ }
+ return null;
+ })}
+
+
+ );
+}
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 (
+
+ );
+}
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 (
+
+
{
+ e.currentTarget.src = `${process.env.basePath || ''}/images/${type}/unknown.png`;
+ }}
+ alt={value}
+ width={type === 'country' ? undefined : 16}
+ height={type === 'country' ? undefined : 16}
+ />
+ {children}
+
+ );
+}
--
cgit v1.2.3