Merge branch 'dev' into search-formatted-metrics

This commit is contained in:
Mike Cao
2024-11-28 16:36:29 -08:00
committed by GitHub
807 changed files with 45367 additions and 8474 deletions

View File

@@ -1,17 +1,20 @@
import { useMemo } from 'react';
import { useTheme } from 'components/hooks';
import Chart, { ChartProps } from 'components/charts/Chart';
import { renderNumberLabels } from 'lib/charts';
import { useState } from 'react';
import BarChartTooltip from 'components/charts/BarChartTooltip';
import Chart, { ChartProps } from 'components/charts/Chart';
import { useTheme } from 'components/hooks';
import { renderNumberLabels } from 'lib/charts';
import { useMemo, useState } from 'react';
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?: number | string;
maxDate?: number | string;
isAllTime?: boolean;
}
export function BarChart(props: BarChartProps) {
@@ -24,14 +27,20 @@ export function BarChart(props: BarChartProps) {
XAxisType = 'time',
YAxisType = 'linear',
stacked = false,
minDate,
maxDate,
currency,
isAllTime,
} = props;
const options = useMemo(() => {
const options: any = useMemo(() => {
return {
scales: {
x: {
type: XAxisType,
stacked: true,
min: isAllTime ? '' : minDate,
max: maxDate,
time: {
unit,
},
@@ -71,7 +80,9 @@ export function BarChart(props: BarChartProps) {
const handleTooltip = ({ tooltip }: { tooltip: any }) => {
const { opacity } = tooltip;
setTooltip(opacity ? <BarChartTooltip tooltip={tooltip} unit={unit} /> : null);
setTooltip(
opacity ? <BarChartTooltip tooltip={tooltip} unit={unit} currency={currency} /> : null,
);
};
return (

View File

@@ -1,7 +1,7 @@
import { formatDate } from 'lib/date';
import { Flexbox, StatusLight } from 'react-basics';
import { formatLongNumber } from 'lib/format';
import { useLocale } from 'components/hooks';
import { formatDate } from 'lib/date';
import { formatLongCurrency, formatLongNumber } from 'lib/format';
import { Flexbox, StatusLight } from 'react-basics';
const formats = {
millisecond: 'T',
@@ -15,16 +15,21 @@ const formats = {
year: 'yyyy',
};
export default function BarChartTooltip({ tooltip, unit }) {
export default function BarChartTooltip({ tooltip, unit, currency }) {
const { locale } = useLocale();
const { labelColors, dataPoints } = tooltip;
return (
<Flexbox direction="column" gap={10}>
<div>{formatDate(new Date(dataPoints[0].raw.x), formats[unit], locale)}</div>
<div>
{formatDate(new Date(dataPoints[0].raw.d || dataPoints[0].raw.x), formats[unit], locale)}
</div>
<div>
<StatusLight color={labelColors?.[0]?.backgroundColor}>
{formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label}
{currency
? formatLongCurrency(dataPoints[0].raw.y, currency)
: formatLongNumber(dataPoints[0].raw.y)}{' '}
{dataPoints[0].dataset.label}
</StatusLight>
</div>
</Flexbox>

View File

@@ -0,0 +1,27 @@
import { Chart, ChartProps } from 'components/charts/Chart';
import { useState } from 'react';
import { StatusLight } from 'react-basics';
import { formatLongNumber } from 'lib/format';
export interface BubbleChartProps extends ChartProps {
type?: 'bubble';
}
export default function BubbleChart(props: BubbleChartProps) {
const [tooltip, setTooltip] = useState(null);
const { type = 'bubble' } = props;
const handleTooltip = ({ tooltip }) => {
const { labelColors, dataPoints } = tooltip;
setTooltip(
tooltip.opacity ? (
<StatusLight color={labelColors?.[0]?.backgroundColor}>
{formatLongNumber(dataPoints?.[0]?.raw)} {dataPoints?.[0]?.label}
</StatusLight>
) : null,
);
};
return <Chart {...props} type={type} tooltip={tooltip} onTooltip={handleTooltip} />;
}

View File

@@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useMemo, ReactNode } from 'react';
import { Loading } from 'react-basics';
import classNames from 'classnames';
import ChartJS, { LegendItem } from 'chart.js/auto';
import ChartJS, { LegendItem, ChartOptions } from 'chart.js/auto';
import HoverTooltip from 'components/common/HoverTooltip';
import Legend from 'components/metrics/Legend';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
@@ -17,7 +17,7 @@ export interface ChartProps {
onUpdate?: (chart: any) => void;
onTooltip?: (model: any) => void;
className?: string;
chartOptions?: { [key: string]: any };
chartOptions?: ChartOptions;
tooltip?: ReactNode;
}
@@ -79,24 +79,30 @@ export function Chart({
};
const updateChart = (data: any) => {
chart.current.data.datasets.forEach((dataset: { data: any }, index: string | number) => {
if (data?.datasets[index]) {
dataset.data = data?.datasets[index]?.data;
if (data.datasets) {
if (data.datasets.length === chart.current.data.datasets.length) {
chart.current.data.datasets.forEach((dataset: { data: any }, index: string | number) => {
if (data?.datasets[index]) {
dataset.data = data?.datasets[index]?.data;
if (chart.current.legend.legendItems[index]) {
chart.current.legend.legendItems[index].text = data?.datasets[index]?.label;
}
if (chart.current.legend.legendItems[index]) {
chart.current.legend.legendItems[index].text = data?.datasets[index]?.label;
}
}
});
} else {
chart.current.data.datasets = data.datasets;
}
});
}
chart.current.options = options;
// Allow config changes before update
onUpdate?.(chart.current);
setLegendItems(chart.current.legend.legendItems);
chart.current.update(updateMode);
setLegendItems(chart.current.legend.legendItems);
};
useEffect(() => {

View File

@@ -9,7 +9,7 @@ export interface PieChartProps extends ChartProps {
export default function PieChart(props: PieChartProps) {
const [tooltip, setTooltip] = useState(null);
const { type } = props;
const { type = 'pie' } = props;
const handleTooltip = ({ tooltip }) => {
const { labelColors, dataPoints } = tooltip;
@@ -23,5 +23,5 @@ export default function PieChart(props: PieChartProps) {
);
};
return <Chart {...props} type={type || 'pie'} tooltip={tooltip} onTooltip={handleTooltip} />;
return <Chart {...props} type={type} tooltip={tooltip} onTooltip={handleTooltip} />;
}

View File

@@ -1,71 +1,23 @@
import md5 from 'md5';
import { colord, extend } from 'colord';
import harmoniesPlugin from 'colord/plugins/harmonies';
import mixPlugin from 'colord/plugins/mix';
import { useMemo } from 'react';
import { createAvatar } from '@dicebear/core';
import { lorelei } from '@dicebear/collection';
import { getColor, getPastel } from 'lib/colors';
extend([harmoniesPlugin, mixPlugin]);
const lib = lorelei;
const harmonies = [
//'analogous',
//'complementary',
'double-split-complementary',
//'rectangle',
'split-complementary',
'tetradic',
//'triadic',
];
function Avatar({ seed, size = 128, ...props }: { seed: string; size?: number }) {
const backgroundColor = getPastel(getColor(seed), 4);
const color = (value: string, invert: boolean = false) => {
const c = colord(value.startsWith('#') ? value : `#${value}`);
const avatar = useMemo(() => {
return createAvatar(lib, {
...props,
seed,
size,
backgroundColor: [backgroundColor],
}).toDataUri();
}, []);
if (invert && c.isDark()) {
return c.invert();
}
return c;
};
const remix = (hash: string) => {
const a = hash.substring(0, 6);
const b = hash.substring(6, 12);
const c = hash.substring(12, 18);
const d = hash.substring(18, 24);
const e = hash.substring(24, 30);
const f = hash.substring(30, 32);
const base = [b, c, d, e]
.reduce((acc, val) => {
return acc.mix(color(val), 0.05);
}, color(a))
.saturate(0.1)
.toHex();
const harmony = pick(parseInt(f, 16), harmonies);
return color(base, true)
.harmonies(harmony)
.map(c => c.toHex());
};
const pick = (num: number, arr: any[]) => {
return arr[num % arr.length];
};
export function Avatar({ value }: { value: string }) {
const hash = md5(value);
const colors = remix(hash);
return (
<svg viewBox="0 0 100 100">
<defs>
<linearGradient id={`color-${hash}`} gradientTransform="rotate(90)">
<stop offset="0%" stopColor={colors[1]} />
<stop offset="100%" stopColor={colors[2]} />
</linearGradient>
</defs>
<circle cx="50" cy="50" r="50" fill={`url(#color-${hash})`} />
</svg>
);
return <img src={avatar} alt="Avatar" style={{ borderRadius: '100%' }} />;
}
export default Avatar;

View File

@@ -1,22 +1,8 @@
.table {
grid-template-rows: repeat(auto-fit, max-content);
}
.table td {
align-items: center;
max-height: max-content;
}
.search {
max-width: 300px;
margin: 20px 0;
}
.action {
justify-content: flex-end;
gap: 5px;
}
.body {
display: flex;
flex-direction: column;

View File

@@ -1,19 +1,22 @@
import { ReactNode } from 'react';
import classNames from 'classnames';
import { Banner, Loading, SearchField } from 'react-basics';
import { useMessages } from 'components/hooks';
import { Loading, SearchField } from 'react-basics';
import { useMessages, useNavigation } from 'components/hooks';
import Empty from 'components/common/Empty';
import Pager from 'components/common/Pager';
import { PagedQueryResult } from 'lib/types';
import styles from './DataTable.module.css';
import { FilterQueryResult } from 'lib/types';
import { LoadingPanel } from 'components/common/LoadingPanel';
const DEFAULT_SEARCH_DELAY = 600;
export interface DataTableProps {
queryResult: FilterQueryResult<any>;
queryResult: PagedQueryResult<any>;
searchDelay?: number;
allowSearch?: boolean;
allowPaging?: boolean;
autoFocus?: boolean;
renderEmpty?: () => ReactNode;
children: ReactNode | ((data: any) => ReactNode);
}
@@ -22,6 +25,8 @@ export function DataTable({
searchDelay = 600,
allowSearch = true,
allowPaging = true,
autoFocus = true,
renderEmpty,
children,
}: DataTableProps) {
const { formatMessage, labels, messages } = useMessages();
@@ -29,12 +34,13 @@ export function DataTable({
result,
params,
setParams,
query: { error, isLoading },
query: { error, isLoading, isFetched },
} = queryResult || {};
const { page, pageSize, count, data } = result || {};
const { query } = params || {};
const hasData = Boolean(!isLoading && data?.length);
const noResults = Boolean(!isLoading && query && !hasData);
const noResults = Boolean(query && !hasData);
const { router, renderUrl } = useNavigation();
const handleSearch = (query: string) => {
setParams({ ...params, query, page: params.page ? page : 1 });
@@ -42,12 +48,9 @@ export function DataTable({
const handlePageChange = (page: number) => {
setParams({ ...params, query, page });
router.push(renderUrl({ page }));
};
if (error) {
return <Banner variant="error">{formatMessage(messages.error)}</Banner>;
}
return (
<>
{allowSearch && (hasData || query) && (
@@ -56,27 +59,31 @@ export function DataTable({
value={query}
onSearch={handleSearch}
delay={searchDelay || DEFAULT_SEARCH_DELAY}
autoFocus={true}
autoFocus={autoFocus}
placeholder={formatMessage(labels.search)}
/>
)}
<div
className={classNames(styles.body, { [styles.status]: isLoading || noResults || !hasData })}
>
{hasData ? (typeof children === 'function' ? children(result) : children) : null}
{isLoading && <Loading position="page" />}
{!isLoading && !hasData && !query && <Empty />}
{noResults && <Empty message={formatMessage(messages.noResultsFound)} />}
</div>
{allowPaging && hasData && (
<Pager
className={styles.pager}
page={page}
pageSize={pageSize}
count={count}
onPageChange={handlePageChange}
/>
)}
<LoadingPanel data={data} isLoading={isLoading} isFetched={isFetched} error={error}>
<div
className={classNames(styles.body, {
[styles.status]: isLoading || noResults || !hasData,
})}
>
{hasData ? (typeof children === 'function' ? children(result) : children) : null}
{isLoading && <Loading position="page" />}
{!isLoading && !hasData && !query && (renderEmpty ? renderEmpty() : <Empty />)}
{!isLoading && noResults && <Empty message={formatMessage(messages.noResultsFound)} />}
</div>
{allowPaging && hasData && (
<Pager
className={styles.pager}
page={page}
pageSize={pageSize}
count={count}
onPageChange={handlePageChange}
/>
)}
</LoadingPanel>
</>
);
}

View File

@@ -1,3 +0,0 @@
.favicon {
margin-inline-end: 8px;
}

View File

@@ -1,7 +1,5 @@
import styles from './Favicon.module.css';
function getHostName(url: string) {
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?=]+)/im);
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?([^:/\n?=]+)/im);
return match && match.length > 1 ? match[1] : null;
}
@@ -14,7 +12,6 @@ export function Favicon({ domain, ...props }) {
return hostName ? (
<img
className={styles.favicon}
src={`https://icons.duckduckgo.com/ip3/${hostName}.ico`}
width={16}
height={16}

View File

@@ -0,0 +1,16 @@
.panel {
display: flex;
flex-direction: column;
position: relative;
flex: 1;
height: 100%;
}
.loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
}

View File

@@ -0,0 +1,36 @@
import { ReactNode } from 'react';
import classNames from 'classnames';
import { Loading } from 'react-basics';
import ErrorMessage from 'components/common/ErrorMessage';
import Empty from 'components/common/Empty';
import styles from './LoadingPanel.module.css';
export function LoadingPanel({
data,
error,
isFetched,
isLoading,
loadingIcon = 'dots',
className,
children,
}: {
data?: any;
error?: Error;
isFetched?: boolean;
isLoading?: boolean;
loadingIcon?: 'dots' | 'spinner';
isEmpty?: boolean;
className?: string;
children: ReactNode;
}) {
const isEmpty = !isLoading && isFetched && data && Array.isArray(data) && data.length === 0;
return (
<div className={classNames(styles.panel, className)}>
{isLoading && !isFetched && <Loading className={styles.loading} icon={loadingIcon} />}
{error && <ErrorMessage />}
{!error && isEmpty && <Empty />}
{!error && !isEmpty && data && children}
</div>
);
}

View File

@@ -27,6 +27,6 @@
}
.nav {
justify-content: end;
justify-content: flex-end;
}
}

View File

@@ -0,0 +1,30 @@
import { ReactNode } from 'react';
export function TypeIcon({
type,
value,
children,
}: {
type: 'browser' | 'country' | 'device' | 'os';
value: string;
children?: ReactNode;
}) {
return (
<>
<img
src={`${process.env.basePath || ''}/images/${type}/${value
?.replaceAll(' ', '-')
.toLowerCase()}.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}
</>
);
}
export default TypeIcon;

View File

@@ -1,10 +1,18 @@
export * from './queries/useApi';
export * from './queries/useConfig';
export * from './queries/useFilterQuery';
export * from './queries/useEventDataEvents';
export * from './queries/useEventDataProperties';
export * from './queries/useEventDataValues';
export * from './queries/useLogin';
export * from './queries/useRealtime';
export * from './queries/useReport';
export * from './queries/useReports';
export * from './queries/useSessionActivity';
export * from './queries/useSessionData';
export * from './queries/useSessionDataProperties';
export * from './queries/useSessionDataValues';
export * from './queries/useWebsiteSession';
export * from './queries/useWebsiteSessions';
export * from './queries/useWebsiteSessionsWeekly';
export * from './queries/useShareToken';
export * from './queries/useTeam';
export * from './queries/useTeams';
@@ -15,8 +23,10 @@ export * from './queries/useUsers';
export * from './queries/useWebsite';
export * from './queries/useWebsites';
export * from './queries/useWebsiteEvents';
export * from './queries/useWebsiteEventsSeries';
export * from './queries/useWebsiteMetrics';
export * from './queries/useWebsiteValues';
export * from './useApi';
export * from './useCountryNames';
export * from './useDateRange';
export * from './useDocumentClick';
@@ -30,6 +40,8 @@ export * from './useLocale';
export * from './useMessages';
export * from './useModified';
export * from './useNavigation';
export * from './usePagedQuery';
export * from './useRegionNames';
export * from './useSticky';
export * from './useTeamUrl';
export * from './useTheme';

View File

@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import useStore, { setConfig } from 'store/app';
import { useApi } from './useApi';
import { useApi } from '../useApi';
let loading = false;

View File

@@ -0,0 +1,20 @@
import { useApi } from '../useApi';
import { UseQueryOptions } from '@tanstack/react-query';
import { useFilterParams } from '../useFilterParams';
export function useEventDataEvents(
websiteId: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const params = useFilterParams(websiteId);
return useQuery({
queryKey: ['websites:event-data:events', { websiteId, ...params }],
queryFn: () => get(`/websites/${websiteId}/event-data/events`, { ...params }),
enabled: !!websiteId,
...options,
});
}
export default useEventDataEvents;

View File

@@ -0,0 +1,20 @@
import { UseQueryOptions } from '@tanstack/react-query';
import { useApi } from '../useApi';
import { useFilterParams } from '../useFilterParams';
export function useEventDataProperties(
websiteId: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const params = useFilterParams(websiteId);
return useQuery<any>({
queryKey: ['websites:event-data:properties', { websiteId, ...params }],
queryFn: () => get(`/websites/${websiteId}/event-data/properties`, { ...params }),
enabled: !!websiteId,
...options,
});
}
export default useEventDataProperties;

View File

@@ -0,0 +1,23 @@
import { UseQueryOptions } from '@tanstack/react-query';
import { useApi } from '../useApi';
import { useFilterParams } from '../useFilterParams';
export function useEventDataValues(
websiteId: string,
eventName: string,
propertyName: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const params = useFilterParams(websiteId);
return useQuery<any>({
queryKey: ['websites:event-data:values', { websiteId, eventName, propertyName, ...params }],
queryFn: () =>
get(`/websites/${websiteId}/event-data/values`, { ...params, eventName, propertyName }),
enabled: !!(websiteId && propertyName),
...options,
});
}
export default useEventDataValues;

View File

@@ -1,6 +1,6 @@
import useStore, { setUser } from 'store/app';
import useApi from './useApi';
import { UseQueryResult } from '@tanstack/react-query';
import useStore, { setUser } from 'store/app';
import { useApi } from '../useApi';
const selector = (state: { user: any }) => state.user;

View File

@@ -1,87 +1,21 @@
import { useMemo, useRef } from 'react';
import { useTimezone } from 'components/hooks';
import { REALTIME_INTERVAL } from 'lib/constants';
import { RealtimeData } from 'lib/types';
import { useApi } from 'components/hooks';
import { REALTIME_INTERVAL, REALTIME_RANGE } from 'lib/constants';
import { startOfMinute, subMinutes } from 'date-fns';
import { percentFilter } from 'lib/filters';
import thenby from 'thenby';
function mergeData(state = [], data = [], time: number) {
const ids = state.map(({ id }) => id);
return state
.concat(data.filter(({ id }) => !ids.includes(id)))
.filter(({ timestamp }) => timestamp >= time);
}
import { useApi } from '../useApi';
export function useRealtime(websiteId: string) {
const currentData = useRef({
pageviews: [],
sessions: [],
events: [],
countries: [],
visitors: [],
timestamp: 0,
});
const { get, useQuery } = useApi();
const { timezone } = useTimezone();
const { data, isLoading, error } = useQuery<RealtimeData>({
queryKey: ['realtime', websiteId],
queryKey: ['realtime', { websiteId, timezone }],
queryFn: async () => {
const state = currentData.current;
const data = await get(`/realtime/${websiteId}`, { startAt: state?.timestamp || 0 });
const date = subMinutes(startOfMinute(new Date()), REALTIME_RANGE);
const time = date.getTime();
const { pageviews, sessions, events, timestamp } = data;
return {
pageviews: mergeData(state?.pageviews, pageviews, time),
sessions: mergeData(state?.sessions, sessions, time),
events: mergeData(state?.events, events, time),
timestamp,
};
return get(`/realtime/${websiteId}`, { timezone });
},
enabled: !!websiteId,
refetchInterval: REALTIME_INTERVAL,
});
const realtimeData: RealtimeData = useMemo(() => {
if (!data) {
return { pageviews: [], sessions: [], events: [], countries: [], visitors: [], timestamp: 0 };
}
data.countries = percentFilter(
data.sessions
.reduce((arr, data) => {
if (!arr.find(({ id }) => id === data.id)) {
return arr.concat(data);
}
return arr;
}, [])
.reduce((arr: { x: any; y: number }[], { country }: any) => {
if (country) {
const row = arr.find(({ x }) => x === country);
if (!row) {
arr.push({ x: country, y: 1 });
} else {
row.y += 1;
}
}
return arr;
}, [])
.sort(thenby.firstBy('y', -1)),
);
data.visitors = data.sessions.reduce((arr, val) => {
if (!arr.find(({ id }) => id === val.id)) {
return arr.concat(val);
}
return arr;
}, []);
return data;
}, [data]);
return { data: realtimeData, isLoading, error };
return { data, isLoading, error };
}
export default useRealtime;

View File

@@ -1,12 +1,12 @@
import { produce } from 'immer';
import { useCallback, useEffect, useState } from 'react';
import { useApi } from './useApi';
import { useApi } from '../useApi';
import { useTimezone } from '../useTimezone';
import { useMessages } from '../useMessages';
export function useReport(
reportId: string,
defaultParameters: { type: string; parameters: { [key: string]: any } },
defaultParameters?: { type: string; parameters: { [key: string]: any } },
) {
const [report, setReport] = useState(null);
const [isRunning, setIsRunning] = useState(false);

View File

@@ -1,11 +1,11 @@
import useApi from './useApi';
import useFilterQuery from './useFilterQuery';
import useApi from '../useApi';
import usePagedQuery from '../usePagedQuery';
import useModified from '../useModified';
export function useReports({ websiteId, teamId }: { websiteId?: string; teamId?: string }) {
const { modified } = useModified(`reports`);
const { get, del, useMutation } = useApi();
const queryResult = useFilterQuery({
const queryResult = usePagedQuery({
queryKey: ['reports', { websiteId, teamId, modified }],
queryFn: (params: any) => {
return get('/reports', { websiteId, teamId, ...params });

View File

@@ -0,0 +1,18 @@
import { useApi } from '../useApi';
export function useRevenueValues(websiteId: string, startDate: Date, endDate: Date) {
const { get, useQuery } = useApi();
return useQuery({
queryKey: ['revenue:values', { websiteId, startDate, endDate }],
queryFn: () =>
get(`/reports/revenue`, {
websiteId,
startDate,
endDate,
}),
enabled: !!(websiteId && startDate && endDate),
});
}
export default useRevenueValues;

View File

@@ -0,0 +1,21 @@
import { useApi } from '../useApi';
export function useSessionActivity(
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),
});
}

View File

@@ -0,0 +1,12 @@
import { useApi } from '../useApi';
export function useSessionData(websiteId: string, sessionId: string) {
const { get, useQuery } = useApi();
return useQuery({
queryKey: ['session:data', { websiteId, sessionId }],
queryFn: () => {
return get(`/websites/${websiteId}/sessions/${sessionId}/properties`, { websiteId });
},
});
}

View File

@@ -0,0 +1,20 @@
import { useApi } from '../useApi';
import { UseQueryOptions } from '@tanstack/react-query';
import { useFilterParams } from '../useFilterParams';
export function useSessionDataProperties(
websiteId: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const params = useFilterParams(websiteId);
return useQuery<any>({
queryKey: ['websites:event-data:properties', { websiteId, ...params }],
queryFn: () => get(`/websites/${websiteId}/session-data/properties`, { ...params }),
enabled: !!websiteId,
...options,
});
}
export default useSessionDataProperties;

View File

@@ -0,0 +1,21 @@
import { useApi } from '../useApi';
import { UseQueryOptions } from '@tanstack/react-query';
import { useFilterParams } from '../useFilterParams';
export function useSessionDataValues(
websiteId: string,
propertyName: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const params = useFilterParams(websiteId);
return useQuery<any>({
queryKey: ['websites:session-data:values', { websiteId, propertyName, ...params }],
queryFn: () => get(`/websites/${websiteId}/session-data/values`, { ...params, propertyName }),
enabled: !!(websiteId && propertyName),
...options,
});
}
export default useSessionDataValues;

View File

@@ -1,5 +1,5 @@
import useStore, { setShareToken } from 'store/app';
import useApi from './useApi';
import { useApi } from '../useApi';
const selector = (state: { shareToken: string }) => state.shareToken;

View File

@@ -1,4 +1,4 @@
import useApi from './useApi';
import { useApi } from '../useApi';
export function useTeam(teamId: string) {
const { get, useQuery } = useApi();

View File

@@ -1,12 +1,12 @@
import useApi from './useApi';
import useFilterQuery from './useFilterQuery';
import { useApi } from '../useApi';
import usePagedQuery from '../usePagedQuery';
import useModified from '../useModified';
export function useTeamMembers(teamId: string) {
const { get } = useApi();
const { modified } = useModified(`teams:members`);
return useFilterQuery({
return usePagedQuery({
queryKey: ['teams:members', { teamId, modified }],
queryFn: (params: any) => {
return get(`/teams/${teamId}/users`, params);

View File

@@ -1,12 +1,12 @@
import useApi from './useApi';
import useFilterQuery from './useFilterQuery';
import { useApi } from '../useApi';
import { usePagedQuery } from '../usePagedQuery';
import useModified from '../useModified';
export function useTeamWebsites(teamId: string) {
const { get } = useApi();
const { modified } = useModified(`websites`);
return useFilterQuery({
return usePagedQuery({
queryKey: ['teams:websites', { teamId, modified }],
queryFn: (params: any) => {
return get(`/teams/${teamId}/websites`, params);

View File

@@ -1,12 +1,12 @@
import useApi from './useApi';
import useFilterQuery from './useFilterQuery';
import { useApi } from '../useApi';
import { usePagedQuery } from '../usePagedQuery';
import useModified from '../useModified';
export function useTeams(userId: string) {
const { get } = useApi();
const { modified } = useModified(`teams`);
return useFilterQuery({
return usePagedQuery({
queryKey: ['teams', { userId, modified }],
queryFn: (params: any) => {
return get(`/users/${userId}/teams`, params);

View File

@@ -1,4 +1,4 @@
import useApi from './useApi';
import { useApi } from '../useApi';
export function useUser(userId: string, options?: { [key: string]: any }) {
const { get, useQuery } = useApi();

View File

@@ -1,12 +1,12 @@
import useApi from './useApi';
import useFilterQuery from './useFilterQuery';
import { useApi } from '../useApi';
import { usePagedQuery } from '../usePagedQuery';
import useModified from '../useModified';
export function useUsers() {
const { get } = useApi();
const { modified } = useModified(`users`);
return useFilterQuery({
return usePagedQuery({
queryKey: ['users', { modified }],
queryFn: (params: any) => {
return get('/admin/users', {

View File

@@ -1,4 +1,4 @@
import useApi from './useApi';
import { useApi } from '../useApi';
export function useWebsite(websiteId: string, options?: { [key: string]: any }) {
const { get, useQuery } = useApi();

View File

@@ -1,33 +1,19 @@
import useApi from './useApi';
import { useApi } from '../useApi';
import { UseQueryOptions } from '@tanstack/react-query';
import { useDateRange, useNavigation, useTimezone } from 'components/hooks';
import { zonedTimeToUtc } from 'date-fns-tz';
import { useFilterParams } from '../useFilterParams';
import { usePagedQuery } from '../usePagedQuery';
export function useWebsiteEvents(
websiteId: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, unit, offset } = dateRange;
const { timezone } = useTimezone();
const {
query: { url, event },
} = useNavigation();
const { get } = useApi();
const params = useFilterParams(websiteId);
const params = {
startAt: +zonedTimeToUtc(startDate, timezone),
endAt: +zonedTimeToUtc(endDate, timezone),
unit,
offset,
timezone,
url,
event,
};
return useQuery({
queryKey: ['events', { ...params }],
queryFn: () => get(`/websites/${websiteId}/events`, { ...params }),
return usePagedQuery({
queryKey: ['websites:events', { websiteId, ...params }],
queryFn: pageParams =>
get(`/websites/${websiteId}/events`, { ...params, ...pageParams, pageSize: 20 }),
enabled: !!websiteId,
...options,
});

View File

@@ -0,0 +1,20 @@
import { useApi } from '../useApi';
import { UseQueryOptions } from '@tanstack/react-query';
import { useFilterParams } from '../useFilterParams';
export function useWebsiteEventsSeries(
websiteId: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const params = useFilterParams(websiteId);
return useQuery({
queryKey: ['websites:events:series', { websiteId, ...params }],
queryFn: () => get(`/websites/${websiteId}/events/series`, { ...params }),
enabled: !!websiteId,
...options,
});
}
export default useWebsiteEventsSeries;

View File

@@ -1,12 +1,16 @@
import useApi from './useApi';
import { UseQueryOptions } from '@tanstack/react-query';
import { useApi } from '../useApi';
import { useFilterParams } from '../useFilterParams';
import { useSearchParams } from 'next/navigation';
export function useWebsiteMetrics(
websiteId: string,
params?: { [key: string]: any },
queryParams: { type: string; limit?: number; search?: string; startAt?: number; endAt?: number },
options?: Omit<UseQueryOptions & { onDataLoad?: (data: any) => void }, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const params = useFilterParams(websiteId);
const searchParams = useSearchParams();
return useQuery({
queryKey: [
@@ -14,21 +18,21 @@ export function useWebsiteMetrics(
{
websiteId,
...params,
...queryParams,
},
],
queryFn: async () => {
const filters = { ...params };
filters[params.type] = undefined;
const data = await get(`/websites/${websiteId}/metrics`, {
...filters,
...params,
[searchParams.get('view')]: undefined,
...queryParams,
});
options?.onDataLoad?.(data);
return data;
},
enabled: !!websiteId,
...options,
});
}

View File

@@ -1,34 +1,19 @@
import { zonedTimeToUtc } from 'date-fns-tz';
import { useApi, useDateRange, useNavigation, useTimezone } from 'components/hooks';
import { UseQueryOptions } from '@tanstack/react-query';
import { useApi } from '../useApi';
import { useFilterParams } from '..//useFilterParams';
export function useWebsitePageviews(websiteId: string, options?: { [key: string]: string }) {
export function useWebsitePageviews(
websiteId: string,
compare?: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, unit } = dateRange;
const { timezone } = useTimezone();
const {
query: { url, referrer, os, browser, device, country, region, city, title },
} = useNavigation();
const params = {
startAt: +zonedTimeToUtc(startDate, timezone),
endAt: +zonedTimeToUtc(endDate, timezone),
unit,
timezone,
url,
referrer,
os,
browser,
device,
country,
region,
city,
title,
};
const params = useFilterParams(websiteId);
return useQuery({
queryKey: ['websites:pageviews', { websiteId, ...params }],
queryFn: () => get(`/websites/${websiteId}/pageviews`, params),
queryKey: ['websites:pageviews', { websiteId, ...params, compare }],
queryFn: () => get(`/websites/${websiteId}/pageviews`, { ...params, compare }),
enabled: !!websiteId,
...options,
});
}

View File

@@ -0,0 +1,14 @@
import { useApi } from '../useApi';
export function useWebsiteSession(websiteId: string, sessionId: string) {
const { get, useQuery } = useApi();
return useQuery({
queryKey: ['session', { websiteId, sessionId }],
queryFn: () => {
return get(`/websites/${websiteId}/sessions/${sessionId}`);
},
});
}
export default useWebsiteSession;

View File

@@ -0,0 +1,16 @@
import { useApi } from '../useApi';
import { useFilterParams } from '../useFilterParams';
export function useWebsiteSessionStats(websiteId: string, options?: { [key: string]: string }) {
const { get, useQuery } = useApi();
const params = useFilterParams(websiteId);
return useQuery({
queryKey: ['sessions:stats', { websiteId, ...params }],
queryFn: () => get(`/websites/${websiteId}/sessions/stats`, { ...params }),
enabled: !!websiteId,
...options,
});
}
export default useWebsiteSessionStats;

View File

@@ -0,0 +1,24 @@
import { useApi } from '../useApi';
import { usePagedQuery } from '../usePagedQuery';
import useModified from '../useModified';
import { useFilterParams } from 'components/hooks/useFilterParams';
export function useWebsiteSessions(websiteId: string, params?: { [key: string]: string | number }) {
const { get } = useApi();
const { modified } = useModified(`sessions`);
const filters = useFilterParams(websiteId);
return usePagedQuery({
queryKey: ['sessions', { websiteId, modified, ...params, ...filters }],
queryFn: (data: any) => {
return get(`/websites/${websiteId}/sessions`, {
...data,
...params,
...filters,
pageSize: 20,
});
},
});
}
export default useWebsiteSessions;

View File

@@ -0,0 +1,24 @@
import { useApi } from '../useApi';
import useModified from '../useModified';
import { useFilterParams } from 'components/hooks/useFilterParams';
export function useWebsiteSessionsWeekly(
websiteId: string,
params?: { [key: string]: string | number },
) {
const { get, useQuery } = useApi();
const { modified } = useModified(`sessions`);
const filters = useFilterParams(websiteId);
return useQuery({
queryKey: ['sessions', { websiteId, modified, ...params, ...filters }],
queryFn: () => {
return get(`/websites/${websiteId}/sessions/weekly`, {
...params,
...filters,
});
},
});
}
export default useWebsiteSessionsWeekly;

View File

@@ -1,30 +1,18 @@
import { useApi, useDateRange, useNavigation } from 'components/hooks';
import { useApi } from '../useApi';
import { useFilterParams } from '../useFilterParams';
export function useWebsiteStats(websiteId: string, options?: { [key: string]: string }) {
export function useWebsiteStats(
websiteId: string,
compare?: string,
options?: { [key: string]: string },
) {
const { get, useQuery } = useApi();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate } = dateRange;
const {
query: { url, referrer, title, os, browser, device, country, region, city },
} = useNavigation();
const params = {
startAt: +startDate,
endAt: +endDate,
url,
referrer,
title,
os,
browser,
device,
country,
region,
city,
};
const params = useFilterParams(websiteId);
return useQuery({
queryKey: ['websites:stats', { websiteId, ...params }],
queryFn: () => get(`/websites/${websiteId}/stats`, params),
queryKey: ['websites:stats', { websiteId, ...params, compare }],
queryFn: () => get(`/websites/${websiteId}/stats`, { ...params, compare }),
enabled: !!websiteId,
...options,
});
}

View File

@@ -1,4 +1,6 @@
import { useApi } from 'components/hooks';
import { useApi } from '../useApi';
import { useCountryNames, useRegionNames } from 'components/hooks';
import useLocale from '../useLocale';
export function useWebsiteValues({
websiteId,
@@ -14,6 +16,36 @@ export function useWebsiteValues({
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 }],
@@ -22,7 +54,7 @@ export function useWebsiteValues({
type,
startAt: +startDate,
endAt: +endDate,
search,
search: getSearch(type, search),
}),
enabled: !!(websiteId && type && startDate && endDate),
});

View File

@@ -1,5 +1,5 @@
import { useApi } from './useApi';
import { useFilterQuery } from './useFilterQuery';
import { useApi } from '../useApi';
import { usePagedQuery } from '../usePagedQuery';
import { useLogin } from './useLogin';
import useModified from '../useModified';
@@ -11,7 +11,7 @@ export function useWebsites(
const { user } = useLogin();
const { modified } = useModified(`websites`);
return useFilterQuery({
return usePagedQuery({
queryKey: ['websites', { userId, teamId, modified, ...params }],
queryFn: (data: any) => {
return get(teamId ? `/teams/${teamId}/websites` : `/users/${userId || user.id}/websites`, {

View File

@@ -10,7 +10,7 @@ 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`);
const { data } = await httpGet(`${process.env.basePath || ''}/intl/country/${locale}.json`);
if (data) {
countryNames[locale] = data;
@@ -28,7 +28,7 @@ export function useCountryNames(locale: string) {
}
}, [locale]);
return list;
return { countryNames: list };
}
export default useCountryNames;

View File

@@ -1,19 +1,25 @@
import { getMinimumUnit, parseDateRange } from 'lib/date';
import { setItem } from 'next-basics';
import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE } from 'lib/constants';
import websiteStore, { setWebsiteDateRange } from 'store/websites';
import { DATE_RANGE_CONFIG, DEFAULT_DATE_COMPARE, DEFAULT_DATE_RANGE } from 'lib/constants';
import websiteStore, { setWebsiteDateRange, setWebsiteDateCompare } from 'store/websites';
import appStore, { setDateRange } from 'store/app';
import { DateRange } from 'lib/types';
import { useLocale } from './useLocale';
import { useApi } from './queries/useApi';
import { useApi } from './useApi';
export function useDateRange(websiteId?: string): [DateRange, (value: string | DateRange) => void] {
export function useDateRange(websiteId?: string): {
dateRange: DateRange;
saveDateRange: (value: string | DateRange) => void;
dateCompare: string;
saveDateCompare: (value: string) => void;
} {
const { get } = useApi();
const { locale } = useLocale();
const websiteConfig = websiteStore(state => state[websiteId]?.dateRange);
const defaultConfig = DEFAULT_DATE_RANGE;
const globalConfig = appStore(state => state.dateRange);
const dateRange = parseDateRange(websiteConfig || globalConfig || defaultConfig, locale);
const dateCompare = websiteStore(state => state[websiteId]?.dateCompare || DEFAULT_DATE_COMPARE);
const saveDateRange = async (value: DateRange | string) => {
if (websiteId) {
@@ -45,7 +51,11 @@ export function useDateRange(websiteId?: string): [DateRange, (value: string | D
}
};
return [dateRange, saveDateRange];
const saveDateCompare = (value: string) => {
setWebsiteDateCompare(websiteId, value);
};
return { dateRange, saveDateRange, dateCompare, saveDateCompare };
}
export default useDateRange;

View File

@@ -14,6 +14,8 @@ export function useFields() {
{ 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: 'host', type: 'string', label: formatMessage(labels.host) },
{ name: 'tag', type: 'string', label: formatMessage(labels.tag) },
];
return { fields };

View File

@@ -0,0 +1,46 @@
import { useNavigation } from './useNavigation';
import { useDateRange } from './useDateRange';
import { useTimezone } from './useTimezone';
export function useFilterParams(websiteId: string) {
const { dateRange } = useDateRange(websiteId);
const { startDate, endDate, unit } = dateRange;
const { timezone, toUtc } = useTimezone();
const {
query: {
url,
referrer,
title,
query,
host,
os,
browser,
device,
country,
region,
city,
event,
tag,
},
} = useNavigation();
return {
startAt: +toUtc(startDate),
endAt: +toUtc(endDate),
unit,
timezone,
url,
referrer,
title,
query,
host,
os,
browser,
device,
country,
region,
city,
event,
tag,
};
}

View File

@@ -8,8 +8,8 @@ import regions from '../../../public/iso-3166-2.json';
export function useFormat() {
const { formatMessage, labels } = useMessages();
const { locale } = useLocale();
const countryNames = useCountryNames(locale);
const languageNames = useLanguageNames(locale);
const { countryNames } = useCountryNames(locale);
const { languageNames } = useLanguageNames(locale);
const formatOS = (value: string): string => {
return OS_NAMES[value] || value;

View File

@@ -10,7 +10,7 @@ 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`);
const { data } = await httpGet(`${process.env.basePath || ''}/intl/language/${locale}.json`);
if (data) {
languageNames[locale] = data;
@@ -28,7 +28,7 @@ export function useLanguageNames(locale) {
}
}, [locale]);
return list;
return { languageNames: list };
}
export default useLanguageNames;

View File

@@ -19,7 +19,9 @@ export function useLocale() {
const dateLocale = getDateLocale(locale);
async function loadMessages(locale: string) {
const { ok, data } = await httpGet(`${process.env.basePath}/intl/messages/${locale}.json`);
const { ok, data } = await httpGet(
`${process.env.basePath || ''}/intl/messages/${locale}.json`,
);
if (ok) {
messages[locale] = data;

View File

@@ -1,16 +1,18 @@
import { UseQueryOptions } from '@tanstack/react-query';
import { useState } from 'react';
import { PageResult, PageParams, PagedQueryResult } from 'lib/types';
import { useApi } from './useApi';
import { FilterResult, SearchFilter, FilterQueryResult } from 'lib/types';
import { useNavigation } from './useNavigation';
export function useFilterQuery<T = any>({
export function usePagedQuery<T = any>({
queryKey,
queryFn,
...options
}: Omit<UseQueryOptions, 'queryFn'> & { queryFn: (params?: object) => any }): FilterQueryResult<T> {
const [params, setParams] = useState<T | SearchFilter>({
}: Omit<UseQueryOptions, 'queryFn'> & { queryFn: (params?: object) => any }): PagedQueryResult<T> {
const { query: queryParams } = useNavigation();
const [params, setParams] = useState<PageParams>({
query: '',
page: 1,
page: +queryParams.page || 1,
});
const { useQuery } = useApi();
@@ -21,11 +23,11 @@ export function useFilterQuery<T = any>({
});
return {
result: data as FilterResult<any>,
result: data as PageResult<T>,
query,
params,
setParams,
};
}
export default useFilterQuery;
export default usePagedQuery;

View File

@@ -0,0 +1,19 @@
import useCountryNames from './useCountryNames';
import regions from '../../../public/iso-3166-2.json';
export function useRegionNames(locale: string) {
const { countryNames } = useCountryNames(locale);
const getRegionName = (regionCode: string, countryCode?: string) => {
if (!countryCode) {
return regions[regionCode];
}
const region = regionCode.includes('-') ? regionCode : `${countryCode}-${regionCode}`;
return regions[region] ? `${regions[region]}, ${countryNames[countryCode]}` : region;
};
return { regionNames: regions, getRegionName };
}
export default useRegionNames;

View File

@@ -1,5 +1,6 @@
import { setItem } from 'next-basics';
import { TIMEZONE_CONFIG } from 'lib/constants';
import { formatInTimeZone, zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz';
import useStore, { setTimezone } from 'store/app';
const selector = (state: { timezone: string }) => state.timezone;
@@ -12,7 +13,25 @@ export function useTimezone() {
setTimezone(value);
};
return { timezone, saveTimezone };
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,
);
};
const toUtc = (date: Date | string | number) => {
return zonedTimeToUtc(date, timezone);
};
const fromUtc = (date: Date | string | number) => {
return utcToZonedTime(date, timezone);
};
return { timezone, saveTimezone, formatTimezoneDate, toUtc, fromUtc };
}
export default useTimezone;

View File

@@ -6,10 +6,12 @@ import Bolt from 'assets/bolt.svg';
import Calendar from 'assets/calendar.svg';
import Change from 'assets/change.svg';
import Clock from 'assets/clock.svg';
import Compare from 'assets/compare.svg';
import Dashboard from 'assets/dashboard.svg';
import Eye from 'assets/eye.svg';
import Gear from 'assets/gear.svg';
import Globe from 'assets/globe.svg';
import Location from 'assets/location.svg';
import Lock from 'assets/lock.svg';
import Logo from 'assets/logo.svg';
import Magnet from 'assets/magnet.svg';
@@ -17,6 +19,7 @@ import Moon from 'assets/moon.svg';
import Nodes from 'assets/nodes.svg';
import Overview from 'assets/overview.svg';
import Profile from 'assets/profile.svg';
import PushPin from 'assets/pushpin.svg';
import Reports from 'assets/reports.svg';
import Sun from 'assets/sun.svg';
import User from 'assets/user.svg';
@@ -32,10 +35,12 @@ const icons = {
Calendar,
Change,
Clock,
Compare,
Dashboard,
Eye,
Gear,
Globe,
Location,
Lock,
Logo,
Magnet,
@@ -43,6 +48,7 @@ const icons = {
Nodes,
Overview,
Profile,
PushPin,
Reports,
Sun,
User,

View File

@@ -0,0 +1,3 @@
.dropdown span {
white-space: nowrap;
}

View File

@@ -5,6 +5,8 @@ import DatePickerForm from 'components/metrics/DatePickerForm';
import { useLocale, useMessages } from 'components/hooks';
import Icons from 'components/icons';
import { formatDate, parseDateValue } from 'lib/date';
import styles from './DateFilter.module.css';
import classNames from 'classnames';
export interface DateFilterProps {
value: string;
@@ -123,7 +125,7 @@ export function DateFilter({
return (
<>
<Dropdown
className={className}
className={classNames(className, styles.dropdown)}
items={options}
renderValue={renderValue}
value={value}

View File

@@ -1,4 +1,4 @@
import { Icon, Button, PopupTrigger, Popup, Text } from 'react-basics';
import { Icon, Button, PopupTrigger, Popup } from 'react-basics';
import classNames from 'classnames';
import { languages } from 'lib/lang';
import { useLocale } from 'components/hooks';
@@ -33,7 +33,7 @@ export function LanguageButton() {
className={classNames(styles.item, { [styles.selected]: value === locale })}
onClick={(e: any) => handleSelect(value, close, e)}
>
<Text>{label}</Text>
<span lang={value}>{label}</span>
{value === locale && (
<Icon className={styles.icon}>
<Icons.Check />

View File

@@ -12,7 +12,7 @@ export function RefreshButton({
isLoading?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const [dateRange] = useDateRange(websiteId);
const { dateRange } = useDateRange(websiteId);
function handleClick() {
if (!isLoading && dateRange) {

View File

@@ -6,23 +6,38 @@ import DateFilter from './DateFilter';
import styles from './WebsiteDateFilter.module.css';
import { DateRange } from 'lib/types';
export function WebsiteDateFilter({ websiteId }: { websiteId: string }) {
export function WebsiteDateFilter({
websiteId,
showAllTime = true,
}: {
websiteId: string;
showAllTime?: boolean;
}) {
const { dir } = useLocale();
const [dateRange, setDateRange] = useDateRange(websiteId);
const { dateRange, saveDateRange } = useDateRange(websiteId);
const { value, startDate, endDate, offset } = dateRange;
const disableForward =
value === 'all' || isAfter(getOffsetDateRange(dateRange, 1).startDate, new Date());
const handleChange = (value: string | DateRange) => {
setDateRange(value);
saveDateRange(value);
};
const handleIncrement = (increment: number) => {
setDateRange(getOffsetDateRange(dateRange, increment));
saveDateRange(getOffsetDateRange(dateRange, increment));
};
return (
<div className={styles.container}>
<DateFilter
className={styles.dropdown}
value={value}
startDate={startDate}
endDate={endDate}
offset={offset}
onChange={handleChange}
showAllTime={showAllTime}
/>
{value !== 'all' && !value.startsWith('range') && (
<div className={styles.buttons}>
<Button onClick={() => handleIncrement(-1)}>
@@ -37,15 +52,6 @@ export function WebsiteDateFilter({ websiteId }: { websiteId: string }) {
</Button>
</div>
)}
<DateFilter
className={styles.dropdown}
value={value}
startDate={startDate}
endDate={endDate}
offset={offset}
onChange={handleChange}
showAllTime={true}
/>
</div>
);
}

View File

@@ -8,6 +8,10 @@
border-top: 1px solid var(--base300);
}
.row.compare {
grid-template-columns: max-content 1fr 1fr;
}
.col {
padding: 20px;
min-height: 430px;
@@ -23,6 +27,10 @@
padding-inline-end: 0;
}
.col.one {
grid-column: span 6;
}
.col.two {
grid-column: span 3;
}

View File

@@ -1,6 +1,7 @@
import { CSSProperties } from 'react';
import classNames from 'classnames';
import { mapChildren } from 'react-basics';
// eslint-disable-next-line css-modules/no-unused-class
import styles from './Grid.module.css';
export interface GridProps {
@@ -19,13 +20,13 @@ export function Grid({ className, style, children }: GridProps) {
export function GridRow(props: {
[x: string]: any;
columns?: 'one' | 'two' | 'three' | 'one-two' | 'two-one';
columns?: 'one' | 'two' | 'three' | 'one-two' | 'two-one' | 'compare';
className?: string;
children?: any;
}) {
const { columns = 'two', className, children, ...otherProps } = props;
return (
<div {...otherProps} className={classNames(styles.row, className)}>
<div {...otherProps} className={classNames(styles.row, className, { [styles[columns]]: true })}>
{mapChildren(children, child => {
return <div className={classNames(styles.col, { [styles[columns]]: true })}>{child}</div>;
})}

View File

@@ -8,4 +8,5 @@
margin: 0 auto;
padding: 0 20px;
min-height: calc(100vh - 60px);
min-height: calc(100dvh - 60px);
}

View File

@@ -29,6 +29,7 @@ export const labels = defineMessages({
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' },
@@ -43,6 +44,7 @@ export const labels = defineMessages({
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' },
@@ -86,13 +88,20 @@ export const labels = defineMessages({
leaveTeam: { id: 'label.leave-team', defaultMessage: 'Leave team' },
refresh: { id: 'label.refresh', defaultMessage: 'Refresh' },
pages: { id: 'label.pages', defaultMessage: 'Pages' },
entry: { id: 'label.entry', defaultMessage: 'Entry path' },
exit: { id: 'label.exit', defaultMessage: 'Exit path' },
referrers: { id: 'label.referrers', defaultMessage: 'Referrers' },
hosts: { id: 'label.hosts', defaultMessage: 'Hosts' },
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' },
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' },
query: { id: 'label.query', defaultMessage: 'Query' },
@@ -105,6 +114,7 @@ export const labels = defineMessages({
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' },
@@ -119,16 +129,17 @@ export const labels = defineMessages({
selectRole: { id: 'label.select-role', defaultMessage: 'Select role' },
selectDate: { id: 'label.select-date', defaultMessage: 'Select date' },
all: { id: 'label.all', defaultMessage: 'All' },
session: { id: 'label.session', defaultMessage: 'Session' },
sessions: { id: 'label.sessions', defaultMessage: 'Sessions' },
pageNotFound: { id: 'message.page-not-found', defaultMessage: 'Page not found' },
activityLog: { id: 'label.activity-log', defaultMessage: 'Activity log' },
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' },
averageVisitTime: { id: 'label.average-visit-time', defaultMessage: 'Average visit time' },
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' },
@@ -141,13 +152,22 @@ export const labels = defineMessages({
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' },
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.',
},
currency: { id: 'label.currency', defaultMessage: 'Currency' },
url: { id: 'label.url', defaultMessage: 'URL' },
urls: { id: 'label.urls', defaultMessage: 'URLs' },
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' },
@@ -176,8 +196,6 @@ export const labels = defineMessages({
before: { id: 'label.before', defaultMessage: 'Before' },
after: { id: 'label.after', defaultMessage: 'After' },
total: { id: 'label.total', defaultMessage: 'Total' },
sum: { id: 'label.sum', defaultMessage: 'Sum' },
average: { id: 'label.average', defaultMessage: 'Average' },
min: { id: 'label.min', defaultMessage: 'Min' },
max: { id: 'label.max', defaultMessage: 'Max' },
unique: { id: 'label.unique', defaultMessage: 'Unique' },
@@ -196,12 +214,14 @@ export const labels = defineMessages({
},
dropoff: { id: 'label.dropoff', defaultMessage: 'Dropoff' },
referrer: { id: 'label.referrer', defaultMessage: 'Referrer' },
host: { id: 'label.host', defaultMessage: 'Host' },
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' },
day: { id: 'label.day', defaultMessage: 'Day' },
date: { id: 'label.date', defaultMessage: 'Date' },
pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
@@ -214,10 +234,16 @@ export const labels = defineMessages({
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',
@@ -232,7 +258,28 @@ export const labels = defineMessages({
defaultMessage: 'Track your campaigns through UTM parameters.',
},
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' },
journeyDescription: {
id: 'label.journey-description',
defaultMessage: 'Understand how users navigate through your website.',
},
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' },
});
export const messages = defineMessages({

View File

@@ -2,6 +2,7 @@ import FilterLink from 'components/common/FilterLink';
import MetricsTable, { MetricsTableProps } from 'components/metrics/MetricsTable';
import { useMessages } from 'components/hooks';
import { useFormat } from 'components/hooks';
import TypeIcon from 'components/common/TypeIcon';
export function BrowsersTable(props: MetricsTableProps) {
const { formatMessage, labels } = useMessages();
@@ -10,12 +11,7 @@ export function BrowsersTable(props: MetricsTableProps) {
function renderLink({ x: browser }) {
return (
<FilterLink id="browser" value={browser} label={formatBrowser(browser)}>
<img
src={`${process.env.basePath}/images/browsers/${browser || 'unknown'}.png`}
alt={browser}
width={16}
height={16}
/>
<TypeIcon type="browser" value={browser} />
</FilterLink>
);
}

View File

@@ -0,0 +1,26 @@
.label {
display: flex;
align-items: center;
gap: 5px;
font-size: 13px;
font-weight: 700;
padding: 0.1em 0.5em;
border-radius: 5px;
color: var(--base500);
align-self: flex-start;
}
.positive {
color: var(--green700);
background: var(--green100);
}
.negative {
color: var(--red700);
background: var(--red100);
}
.neutral {
color: var(--base700);
background: var(--base100);
}

View File

@@ -0,0 +1,46 @@
import classNames from 'classnames';
import { Icon, Icons } from 'react-basics';
import { ReactNode } from 'react';
import styles from './ChangeLabel.module.css';
export function ChangeLabel({
value,
size,
title,
reverseColors,
className,
children,
}: {
value: number;
size?: 'xs' | 'sm' | 'md' | 'lg';
title?: string;
reverseColors?: boolean;
showPercentage?: boolean;
className?: string;
children?: ReactNode;
}) {
const positive = value >= 0;
const negative = value < 0;
const neutral = value === 0 || isNaN(value);
const good = reverseColors ? negative : positive;
return (
<div
className={classNames(styles.label, className, {
[styles.positive]: good,
[styles.negative]: !good,
[styles.neutral]: neutral,
})}
title={title}
>
{!neutral && (
<Icon rotate={positive ? -90 : 90} size={size}>
<Icons.ArrowRight />
</Icon>
)}
{children || value}
</div>
);
}
export default ChangeLabel;

View File

@@ -1,6 +1,9 @@
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import { emptyFilter } from 'lib/filters';
import FilterLink from 'components/common/FilterLink';
import TypeIcon from 'components/common/TypeIcon';
import { useLocale } from 'components/hooks';
import { useMessages } from 'components/hooks';
import { useFormat } from 'components/hooks';

View File

@@ -2,34 +2,22 @@ import FilterLink from 'components/common/FilterLink';
import { useCountryNames } from 'components/hooks';
import { useLocale, useMessages, useFormat } from 'components/hooks';
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import TypeIcon from 'components/common/TypeIcon';
export function CountriesTable({
onDataLoad,
...props
}: {
onDataLoad: (data: any) => void;
} & MetricsTableProps) {
export function CountriesTable({ ...props }: MetricsTableProps) {
const { locale } = useLocale();
const countryNames = useCountryNames(locale);
const { countryNames } = useCountryNames(locale);
const { formatMessage, labels } = useMessages();
const { formatCountry } = useFormat();
const handleDataLoad = (data: any) => {
onDataLoad?.(data);
};
const renderLink = ({ x: code }) => {
return (
<FilterLink
id="country"
className={locale}
value={countryNames[code] && code}
value={(countryNames[code] && code) || code}
label={formatCountry(code)}
>
<img
src={`${process.env.basePath}/images/flags/${code?.toLowerCase() || 'xx'}.png`}
alt={code}
/>
<TypeIcon type="country" value={code?.toLowerCase()} />
</FilterLink>
);
};

View File

@@ -2,6 +2,7 @@ import MetricsTable, { MetricsTableProps } from './MetricsTable';
import FilterLink from 'components/common/FilterLink';
import { useMessages } from 'components/hooks';
import { useFormat } from 'components/hooks';
import TypeIcon from 'components/common/TypeIcon';
export function DevicesTable(props: MetricsTableProps) {
const { formatMessage, labels } = useMessages();
@@ -10,12 +11,7 @@ export function DevicesTable(props: MetricsTableProps) {
function renderLink({ x: device }) {
return (
<FilterLink id="device" value={labels[device] && device} label={formatDevice(device)}>
<img
src={`${process.env.basePath}/images/device/${device?.toLowerCase() || 'unknown'}.png`}
alt={device}
width={16}
height={16}
/>
<TypeIcon type="device" value={device?.toLowerCase()} />
</FilterLink>
);
}

View File

@@ -1,11 +1,9 @@
import { useMemo } from 'react';
import { Loading } from 'react-basics';
import { colord } from 'colord';
import BarChart from 'components/charts/BarChart';
import { getDateArray } from 'lib/date';
import { useLocale, useDateRange, useWebsiteEvents } from 'components/hooks';
import { CHART_COLORS } from 'lib/constants';
import { useDateRange, useLocale, useWebsiteEventsSeries } from 'components/hooks';
import { renderDateLabels } from 'lib/charts';
import { CHART_COLORS } from 'lib/constants';
import { useMemo } from 'react';
export interface EventsChartProps {
websiteId: string;
@@ -13,9 +11,11 @@ export interface EventsChartProps {
}
export function EventsChart({ websiteId, className }: EventsChartProps) {
const [{ startDate, endDate, unit }] = useDateRange(websiteId);
const {
dateRange: { startDate, endDate, unit, value },
} = useDateRange(websiteId);
const { locale } = useLocale();
const { data, isLoading } = useWebsiteEvents(websiteId);
const { data, isLoading } = useWebsiteEventsSeries(websiteId);
const chartData = useMemo(() => {
if (!data) return [];
@@ -30,10 +30,6 @@ export function EventsChart({ websiteId, className }: EventsChartProps) {
return obj;
}, {});
Object.keys(map).forEach(key => {
map[key] = getDateArray(map[key], startDate, endDate, unit);
});
return {
datasets: Object.keys(map).map((key, index) => {
const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
@@ -49,18 +45,17 @@ export function EventsChart({ websiteId, className }: EventsChartProps) {
};
}, [data, startDate, endDate, unit]);
if (isLoading) {
return <Loading icon="dots" />;
}
return (
<BarChart
minDate={startDate.toISOString()}
maxDate={endDate.toISOString()}
className={className}
data={chartData}
unit={unit}
stacked={true}
renderXLabel={renderDateLabels(unit, locale)}
isLoading={isLoading}
isAllTime={value === 'all'}
/>
);
}

View File

@@ -2,6 +2,12 @@
display: flex;
align-items: center;
gap: 10px;
background: var(--base75);
padding: 10px 20px;
border: 1px solid var(--base400);
border-radius: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.label {
@@ -12,12 +18,13 @@
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
background: var(--base75);
gap: 4px;
font-size: 12px;
background: var(--base50);
border: 1px solid var(--base400);
border-radius: var(--border-radius);
box-shadow: 1px 1px 1px var(--base500);
padding: 8px 16px;
padding: 6px 14px;
cursor: pointer;
}
@@ -27,6 +34,8 @@
.close {
font-weight: 700;
align-self: center;
margin-left: auto;
}
.name,

View File

@@ -13,6 +13,7 @@ import FieldFilterEditForm from 'app/(main)/reports/[reportId]/FieldFilterEditFo
import { OPERATOR_PREFIXES } from 'lib/constants';
import { isSearchOperator, parseParameterValue } from 'lib/params';
import styles from './FilterTags.module.css';
import WebsiteFilterButton from 'app/(main)/websites/[websiteId]/WebsiteFilterButton';
export function FilterTags({
websiteId,
@@ -23,7 +24,7 @@ export function FilterTags({
}) {
const { formatMessage, labels } = useMessages();
const { formatValue } = useFormat();
const [dateRange] = useDateRange(websiteId);
const { dateRange } = useDateRange(websiteId);
const {
router,
renderUrl,
@@ -100,6 +101,7 @@ export function FilterTags({
</PopupTrigger>
);
})}
<WebsiteFilterButton websiteId={websiteId} alignment="center" showText={false} />
<Button className={styles.close} variant="quiet" onClick={handleResetFilter}>
<Icon>
<Icons.Close />

View File

@@ -0,0 +1,35 @@
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import FilterLink from 'components/common/FilterLink';
import { useMessages } from 'components/hooks';
import { Flexbox } from 'react-basics';
export function HostsTable(props: MetricsTableProps) {
const { formatMessage, labels } = useMessages();
const renderLink = ({ x: host }) => {
return (
<Flexbox alignItems="center">
<FilterLink
id="host"
value={host}
externalUrl={`https://${host}`}
label={!host && formatMessage(labels.none)}
/>
</Flexbox>
);
};
return (
<>
<MetricsTable
{...props}
title={formatMessage(labels.hosts)}
type="host"
metric={formatMessage(labels.visitors)}
renderLabel={renderLink}
/>
</>
);
}
export default HostsTable;

View File

@@ -3,7 +3,6 @@ import { safeDecodeURIComponent } from 'next-basics';
import { colord } from 'colord';
import classNames from 'classnames';
import { LegendItem } from 'chart.js/auto';
import { useLocale } from 'components/hooks';
import styles from './Legend.module.css';
export function Legend({
@@ -13,8 +12,6 @@ export function Legend({
items: any[];
onClick: (index: LegendItem) => void;
}) {
const { locale } = useLocale();
if (!items.find(({ text }) => text)) {
return null;
}
@@ -32,7 +29,7 @@ export function Legend({
onClick={() => onClick(item)}
>
<StatusLight color={color.alpha(color.alpha() + 0.2).toHex()}>
<span className={locale}>{safeDecodeURIComponent(text)}</span>
{safeDecodeURIComponent(text)}
</StatusLight>
</div>
);

View File

@@ -72,9 +72,11 @@
}
.value {
width: 50px;
display: flex;
align-items: center;
gap: 10px;
text-align: end;
margin-inline-end: 10px;
margin-inline-end: 5px;
font-weight: 600;
}

View File

@@ -14,7 +14,8 @@ export interface ListTableProps {
title?: string;
metric?: string;
className?: string;
renderLabel?: (row: any) => ReactNode;
renderLabel?: (row: any, index: number) => ReactNode;
renderChange?: (row: any, index: number) => ReactNode;
animate?: boolean;
virtualize?: boolean;
showPercentage?: boolean;
@@ -27,6 +28,7 @@ export function ListTable({
metric,
className,
renderLabel,
renderChange,
animate = true,
virtualize = false,
showPercentage = true,
@@ -34,23 +36,24 @@ export function ListTable({
}: ListTableProps) {
const { formatMessage, labels } = useMessages();
const getRow = row => {
const getRow = (row: { x: any; y: any; z: any }, index: number) => {
const { x: label, y: value, z: percent } = row;
return (
<AnimatedRow
key={label}
label={renderLabel ? renderLabel(row) : label ?? formatMessage(labels.unknown)}
label={renderLabel ? renderLabel(row, index) : label ?? formatMessage(labels.unknown)}
value={value}
percent={percent}
animate={animate && !virtualize}
showPercentage={showPercentage}
change={renderChange ? renderChange(row, index) : null}
/>
);
};
const Row = ({ index, style }) => {
return <div style={style}>{getRow(data[index])}</div>;
return <div style={style}>{getRow(data[index], index)}</div>;
};
return (
@@ -71,14 +74,14 @@ export function ListTable({
{Row}
</FixedSizeList>
) : (
data.map(row => getRow(row))
data.map(getRow)
)}
</div>
</div>
);
}
const AnimatedRow = ({ label, value = 0, percent, animate, showPercentage = true }) => {
const AnimatedRow = ({ label, value = 0, percent, change, animate, showPercentage = true }) => {
const props = useSpring({
width: percent,
y: value,
@@ -90,6 +93,7 @@ const AnimatedRow = ({ label, value = 0, percent, animate, showPercentage = true
<div className={styles.row}>
<div className={styles.label}>{label}</div>
<div className={styles.value}>
{change}
<animated.div className={styles.value} title={props?.y as any}>
{props.y?.to(formatLongNumber)}
</animated.div>
@@ -97,9 +101,7 @@ const AnimatedRow = ({ label, value = 0, percent, animate, showPercentage = true
{showPercentage && (
<div className={styles.percent}>
<animated.div className={styles.bar} style={{ width: props.width.to(n => `${n}%`) }} />
<animated.span className={styles.percentValue}>
{props.width.to(n => `${n?.toFixed?.(0)}%`)}
</animated.span>
<animated.span>{props.width.to(n => `${n?.toFixed?.(0)}%`)}</animated.span>
</div>
)}
</div>

View File

@@ -2,47 +2,36 @@
display: flex;
flex-direction: column;
justify-content: center;
min-height: 90px;
min-width: 140px;
min-width: 150px;
}
.card.compare .change {
font-size: 16px;
margin: 10px 0;
}
.card:first-child {
padding-left: 0;
}
.card:last-child {
border: 0;
}
.value {
display: flex;
align-items: center;
font-size: 36px;
font-weight: 700;
white-space: nowrap;
min-height: 60px;
color: var(--base900);
line-height: 1.5;
}
.label {
display: flex;
align-items: center;
font-weight: 700;
gap: 10px;
white-space: nowrap;
min-height: 30px;
.value.prev {
color: var(--base800);
}
.change {
font-size: 12px;
padding: 0 5px;
border-radius: 5px;
color: var(--base500);
}
.change.positive {
color: var(--green700);
background: var(--green100);
}
.change.negative {
color: var(--red700);
background: var(--red100);
}
.change.plusSign::before {
content: '+';
.label {
font-weight: 700;
white-space: nowrap;
color: var(--base800);
}

View File

@@ -1,15 +1,19 @@
import classNames from 'classnames';
import { useSpring, animated } from '@react-spring/web';
import { formatNumber } from 'lib/format';
import ChangeLabel from 'components/metrics/ChangeLabel';
import styles from './MetricCard.module.css';
export interface MetricCardProps {
value: number;
previousValue?: number;
change?: number;
label: string;
label?: string;
reverseColors?: boolean;
format?: typeof formatNumber;
hideComparison?: boolean;
formatValue?: (n: any) => string;
showLabel?: boolean;
showChange?: boolean;
showPrevious?: boolean;
className?: string;
}
@@ -18,33 +22,39 @@ export const MetricCard = ({
change = 0,
label,
reverseColors = false,
format = formatNumber,
hideComparison = false,
formatValue = formatNumber,
showLabel = true,
showChange = false,
showPrevious = false,
className,
}: 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(change) || 0, from: { x: 0 } });
const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } });
const prevProps = useSpring({ x: Number(diff) || 0, from: { x: 0 } });
return (
<div className={classNames(styles.card, className)}>
<animated.div className={styles.value} title={props?.x as any}>
{props?.x?.to(x => format(x))}
<div className={classNames(styles.card, className, showPrevious && styles.compare)}>
{showLabel && <div className={styles.label}>{label}</div>}
<animated.div className={styles.value} title={value?.toString()}>
{props?.x?.to(x => formatValue(x))}
</animated.div>
<div className={styles.label}>
{label}
{~~change !== 0 && !hideComparison && (
<animated.span
className={classNames(styles.change, {
[styles.positive]: change * (reverseColors ? -1 : 1) >= 0,
[styles.negative]: change * (reverseColors ? -1 : 1) < 0,
[styles.plusSign]: change > 0,
})}
title={changeProps?.x as any}
>
{changeProps?.x?.to(x => format(x))}
</animated.span>
)}
</div>
{showChange && (
<ChangeLabel
className={styles.change}
value={change}
title={formatValue(change)}
reverseColors={reverseColors}
>
<animated.span>{changeProps?.x?.to(x => `${Math.abs(~~x)}%`)}</animated.span>
</ChangeLabel>
)}
{showPrevious && (
<animated.div className={classNames(styles.value, styles.prev)} title={diff.toString()}>
{prevProps?.x?.to(x => formatValue(x))}
</animated.div>
)}
</div>
);
};

View File

@@ -2,6 +2,8 @@
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, max-content));
gap: 20px;
width: 100%;
position: relative;
}
@media screen and (max-width: 768px) {

View File

@@ -6,7 +6,6 @@ import LinkButton from 'components/common/LinkButton';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
import { percentFilter } from 'lib/filters';
import {
useDateRange,
useNavigation,
useWebsiteMetrics,
useMessages,
@@ -19,7 +18,6 @@ import styles from './MetricsTable.module.css';
export interface MetricsTableProps extends ListTableProps {
websiteId: string;
domainName: string;
type?: string;
className?: string;
dataFilter?: (data: any) => any;
@@ -29,6 +27,8 @@ export interface MetricsTableProps extends ListTableProps {
onSearch?: (search: string) => void;
allowSearch?: boolean;
searchFormattedValues?: boolean;
showMore?: boolean;
params?: { [key: string]: any };
children?: ReactNode;
}
@@ -42,21 +42,20 @@ export function MetricsTable({
delay = null,
allowSearch = false,
searchFormattedValues = false,
showMore = true,
params,
children,
...props
}: MetricsTableProps) {
const [search, setSearch] = useState('');
const { formatValue } = useFormat();
const [{ startDate, endDate }] = useDateRange(websiteId);
const {
renderUrl,
query: { url, referrer, title, os, browser, device, country, region, city },
} = useNavigation();
const { renderUrl } = useNavigation();
const { formatMessage, labels } = useMessages();
const { dir } = useLocale();
const { data, isLoading, isFetched, error } = useWebsiteMetrics(
websiteId,
{ type, limit, search, ...params },
{
type,
startAt: +startDate,
@@ -72,8 +71,9 @@ export function MetricsTable({
city,
limit,
search: (searchFormattedValues) ? undefined : search,
retryDelay: delay || DEFAULT_ANIMATION_DURATION,
onDataLoad,
},
{ retryDelay: delay || DEFAULT_ANIMATION_DURATION, onDataLoad },
);
const filteredData = useMemo(() => {
@@ -125,7 +125,7 @@ export function MetricsTable({
)}
{!data && isLoading && !isFetched && <Loading icon="dots" />}
<div className={styles.footer}>
{data && !error && limit && (
{showMore && data && !error && limit && (
<LinkButton href={renderUrl({ view: type })} variant="quiet">
<Text>{formatMessage(labels.more)}</Text>
<Icon size="sm" rotate={dir === 'rtl' ? 180 : 0}>

View File

@@ -1,6 +1,7 @@
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import FilterLink from 'components/common/FilterLink';
import { useMessages, useFormat } from 'components/hooks';
import TypeIcon from 'components/common/TypeIcon';
export function OSTable(props: MetricsTableProps) {
const { formatMessage, labels } = useMessages();
@@ -9,14 +10,7 @@ export function OSTable(props: MetricsTableProps) {
function renderLink({ x: os }) {
return (
<FilterLink id="os" value={os} label={formatOS(os)}>
<img
src={`${process.env.basePath || ''}/images/os/${
os?.toLowerCase()?.replaceAll(/\W/g, '-') || 'unknown'
}.png`}
alt={os}
width={16}
height={16}
/>
<TypeIcon type="os" value={os?.toLowerCase()?.replaceAll(/\W/g, '-')} />
</FilterLink>
);
}

View File

@@ -1,31 +1,41 @@
import FilterLink from 'components/common/FilterLink';
import { WebsiteContext } from 'app/(main)/websites/[websiteId]/WebsiteProvider';
import FilterButtons from 'components/common/FilterButtons';
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import { useMessages } from 'components/hooks';
import { useNavigation } from 'components/hooks';
import FilterLink from 'components/common/FilterLink';
import { useMessages, useNavigation } from 'components/hooks';
import { emptyFilter } from 'lib/filters';
import { useContext } from 'react';
import MetricsTable, { MetricsTableProps } from './MetricsTable';
export interface PagesTableProps extends MetricsTableProps {
allowFilter?: boolean;
}
export function PagesTable({ allowFilter, domainName, ...props }: PagesTableProps) {
export function PagesTable({ allowFilter, ...props }: PagesTableProps) {
const {
router,
renderUrl,
query: { view = 'url' },
} = useNavigation();
const { formatMessage, labels } = useMessages();
const { domain } = useContext(WebsiteContext);
const handleSelect = (key: any) => {
router.push(renderUrl({ view: key }), { scroll: true });
router.push(renderUrl({ view: key }), { scroll: false });
};
const buttons = [
{
label: 'URL',
label: formatMessage(labels.path),
key: 'url',
},
{
label: formatMessage(labels.entry),
key: 'entry',
},
{
label: formatMessage(labels.exit),
key: 'exit',
},
{
label: formatMessage(labels.title),
key: 'title',
@@ -35,10 +45,14 @@ export function PagesTable({ allowFilter, domainName, ...props }: PagesTableProp
const renderLink = ({ x }) => {
return (
<FilterLink
id={view}
id={view === 'entry' || view === 'exit' ? 'url' : view}
value={x}
label={!x && formatMessage(labels.none)}
externalUrl={`${domainName.startsWith('http') ? domainName : `https://${domainName}`}${x}`}
externalUrl={
view !== 'title'
? `${domain.startsWith('http') ? domain : `https://${domain}`}${x}`
: null
}
/>
);
};
@@ -46,7 +60,6 @@ export function PagesTable({ allowFilter, domainName, ...props }: PagesTableProp
return (
<MetricsTable
{...props}
domainName={domainName}
title={formatMessage(labels.pages)}
type={view}
metric={formatMessage(labels.views)}

View File

@@ -3,16 +3,27 @@ import BarChart, { BarChartProps } from 'components/charts/BarChart';
import { useLocale, useTheme, useMessages } from 'components/hooks';
import { renderDateLabels } from 'lib/charts';
export interface PageviewsChartProps extends BarChartProps {
export interface PagepageviewsChartProps extends BarChartProps {
data: {
sessions: any[];
pageviews: any[];
sessions: any[];
compare?: {
pageviews: any[];
sessions: any[];
};
};
unit: string;
isLoading?: boolean;
isAllTime?: boolean;
}
export function PageviewsChart({ data, unit, isLoading, ...props }: PageviewsChartProps) {
export function PagepageviewsChart({
data,
unit,
isLoading,
isAllTime,
...props
}: PagepageviewsChartProps) {
const { formatMessage, labels } = useMessages();
const { colors } = useTheme();
const { locale } = useLocale();
@@ -29,13 +40,37 @@ export function PageviewsChart({ data, unit, isLoading, ...props }: PageviewsCha
data: data.sessions,
borderWidth: 1,
...colors.chart.visitors,
order: 3,
},
{
label: formatMessage(labels.views),
data: data.pageviews,
borderWidth: 1,
...colors.chart.views,
order: 4,
},
...(data.compare
? [
{
type: 'line',
label: `${formatMessage(labels.views)} (${formatMessage(labels.previous)})`,
data: data.compare.pageviews,
borderWidth: 2,
backgroundColor: '#8601B0',
borderColor: '#8601B0',
order: 1,
},
{
type: 'line',
label: `${formatMessage(labels.visitors)} (${formatMessage(labels.previous)})`,
data: data.compare.sessions,
borderWidth: 2,
backgroundColor: '#f15bb5',
borderColor: '#f15bb5',
order: 2,
},
]
: []),
],
};
}, [data, locale]);
@@ -46,9 +81,10 @@ export function PageviewsChart({ data, unit, isLoading, ...props }: PageviewsCha
data={chartData}
unit={unit}
isLoading={isLoading}
isAllTime={isAllTime}
renderXLabel={renderDateLabels(unit, locale)}
/>
);
}
export default PageviewsChart;
export default PagepageviewsChart;

View File

@@ -1,29 +1,9 @@
import { useMemo, useRef } from 'react';
import { format, startOfMinute, subMinutes, isBefore } from 'date-fns';
import { startOfMinute, subMinutes, isBefore } from 'date-fns';
import PageviewsChart from './PageviewsChart';
import { getDateArray } from 'lib/date';
import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from 'lib/constants';
import { RealtimeData } from 'lib/types';
function mapData(data: any[]) {
let last = 0;
const arr = [];
data?.reduce((obj, { timestamp }) => {
const t = startOfMinute(new Date(timestamp));
if (t.getTime() > last) {
obj = { x: format(t, 'yyyy-LL-dd HH:mm:00'), y: 1 };
arr.push(obj);
last = t.getTime();
} else {
obj.y += 1;
}
return obj;
}, {});
return arr;
}
export interface RealtimeChartProps {
data: RealtimeData;
unit: string;
@@ -41,8 +21,8 @@ export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
}
return {
pageviews: getDateArray(mapData(data.pageviews), startDate, endDate, unit),
sessions: getDateArray(mapData(data.visitors), startDate, endDate, unit),
pageviews: data.series.views,
sessions: data.series.visitors,
};
}, [data, startDate, endDate, unit]);
@@ -56,7 +36,14 @@ export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
}, [endDate]);
return (
<PageviewsChart {...props} unit={unit} data={chartData} animationDuration={animationDuration} />
<PageviewsChart
{...props}
minDate={startDate.toISOString()}
maxDate={endDate.toISOString()}
unit={unit}
data={chartData}
animationDuration={animationDuration}
/>
);
}

View File

@@ -1,23 +1,21 @@
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import FilterLink from 'components/common/FilterLink';
import Favicon from 'components/common/Favicon';
import { useMessages } from 'components/hooks';
import { Flexbox } from 'react-basics';
import MetricsTable, { MetricsTableProps } from './MetricsTable';
export function ReferrersTable(props: MetricsTableProps) {
const { formatMessage, labels } = useMessages();
const renderLink = ({ x: referrer }) => {
return (
<Flexbox alignItems="center">
<FilterLink
id="referrer"
value={referrer}
externalUrl={`https://${referrer}`}
label={!referrer && formatMessage(labels.none)}
>
<Favicon domain={referrer} />
<FilterLink
id="referrer"
value={referrer}
externalUrl={`https://${referrer}`}
label={!referrer && formatMessage(labels.none)}
/>
</Flexbox>
</FilterLink>
);
};

View File

@@ -1,28 +1,18 @@
import FilterLink from 'components/common/FilterLink';
import { emptyFilter } from 'lib/filters';
import { useLocale } from 'components/hooks';
import { useMessages } from 'components/hooks';
import { useCountryNames } from 'components/hooks';
import { useMessages, useLocale, useRegionNames } from 'components/hooks';
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import regions from '../../../public/iso-3166-2.json';
import TypeIcon from 'components/common/TypeIcon';
export function RegionsTable(props: MetricsTableProps) {
const { locale } = useLocale();
const { formatMessage, labels } = useMessages();
const countryNames = useCountryNames(locale);
const renderLabel = (code: string, country: string) => {
const region = code.includes('-') ? code : `${country}-${code}`;
return regions[region] ? `${regions[region]}, ${countryNames[country]}` : region;
};
const { getRegionName } = useRegionNames(locale);
const renderLink = ({ x: code, country }) => {
return (
<FilterLink id="region" className={locale} value={code} label={renderLabel(code, country)}>
<img
src={`${process.env.basePath}/images/flags/${country?.toLowerCase() || 'xx'}.png`}
alt={code}
/>
<FilterLink id="region" value={code} label={getRegionName(code, country)}>
<TypeIcon type="country" value={country?.toLowerCase()} />
</FilterLink>
);
};

View File

@@ -0,0 +1,30 @@
import MetricsTable, { MetricsTableProps } from './MetricsTable';
import FilterLink from 'components/common/FilterLink';
import { useMessages } from 'components/hooks';
import { Flexbox } from 'react-basics';
export function TagsTable(props: MetricsTableProps) {
const { formatMessage, labels } = useMessages();
const renderLink = ({ x: tag }) => {
return (
<Flexbox alignItems="center">
<FilterLink id="tag" value={tag} label={!tag && formatMessage(labels.none)} />
</Flexbox>
);
};
return (
<>
<MetricsTable
{...props}
title={formatMessage(labels.tags)}
type="tag"
metric={formatMessage(labels.views)}
renderLabel={renderLink}
/>
</>
);
}
export default TagsTable;

View File

@@ -1,10 +1,10 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, HTMLAttributes } from 'react';
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
import classNames from 'classnames';
import { colord } from 'colord';
import HoverTooltip from 'components/common/HoverTooltip';
import { ISO_COUNTRIES, MAP_FILE } from 'lib/constants';
import { useTheme } from 'components/hooks';
import { useDateRange, useTheme, useWebsiteMetrics } from 'components/hooks';
import { useCountryNames } from 'components/hooks';
import { useLocale } from 'components/hooks';
import { useMessages } from 'components/hooks';
@@ -12,16 +12,37 @@ import { formatLongNumber } from 'lib/format';
import { percentFilter } from 'lib/filters';
import styles from './WorldMap.module.css';
export function WorldMap({ data = [], className }: { data?: any[]; className?: string }) {
export function WorldMap({
websiteId,
data,
className,
...props
}: {
websiteId?: string;
data?: any[];
className?: string;
} & HTMLAttributes<HTMLDivElement>) {
const [tooltip, setTooltipPopup] = useState();
const { theme, colors } = useTheme();
const { locale } = useLocale();
const { formatMessage, labels } = useMessages();
const countryNames = useCountryNames(locale);
const { countryNames } = useCountryNames(locale);
const visitorsLabel = formatMessage(labels.visitors).toLocaleLowerCase(locale);
const metrics = useMemo(() => (data ? percentFilter(data) : []), [data]);
const unknownLabel = formatMessage(labels.unknown);
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
const { data: mapData } = useWebsiteMetrics(websiteId, {
type: 'country',
startAt: +startDate,
endAt: +endDate,
});
const metrics = useMemo(
() => (data || mapData ? percentFilter((data || mapData) as any[]) : []),
[data, mapData],
);
function getFillColor(code: string) {
const getFillColor = (code: string) => {
if (code === 'AQ') return;
const country = metrics?.find(({ x }) => x === code);
@@ -32,29 +53,32 @@ export function WorldMap({ data = [], className }: { data?: any[]; className?: s
return colord(colors.map.baseColor)
[theme === 'light' ? 'lighten' : 'darken'](0.4 * (1.0 - country.z / 100))
.toHex();
}
};
function getOpacity(code) {
const getOpacity = (code: string) => {
return code === 'AQ' ? 0 : 1;
}
};
function handleHover(code) {
const handleHover = (code: string) => {
if (code === 'AQ') return;
const country = metrics?.find(({ x }) => x === code);
setTooltipPopup(
`${countryNames[code]}: ${formatLongNumber(country?.y || 0)} ${visitorsLabel}` as any,
`${countryNames[code] || unknownLabel}: ${formatLongNumber(
country?.y || 0,
)} ${visitorsLabel}` as any,
);
}
};
return (
<div
{...props}
className={classNames(styles.container, className)}
data-tip=""
data-for="world-map-tooltip"
>
<ComposableMap projection="geoMercator">
<ZoomableGroup zoom={0.8} minZoom={0.7} center={[0, 40]}>
<Geographies geography={`${process.env.basePath}${MAP_FILE}`}>
<Geographies geography={`${process.env.basePath || ''}${MAP_FILE}`}>
{({ geographies }) => {
return geographies.map(geo => {
const code = ISO_COUNTRIES[geo.id];