Merge branch 'dev' into search-formatted-metrics
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
27
src/components/charts/BubbleChart.tsx
Normal file
27
src/components/charts/BubbleChart.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.favicon {
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
16
src/components/common/LoadingPanel.module.css
Normal file
16
src/components/common/LoadingPanel.module.css
Normal 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;
|
||||
}
|
||||
36
src/components/common/LoadingPanel.tsx
Normal file
36
src/components/common/LoadingPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,6 @@
|
||||
}
|
||||
|
||||
.nav {
|
||||
justify-content: end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
30
src/components/common/TypeIcon.tsx
Normal file
30
src/components/common/TypeIcon.tsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
20
src/components/hooks/queries/useEventDataEvents.ts
Normal file
20
src/components/hooks/queries/useEventDataEvents.ts
Normal 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;
|
||||
20
src/components/hooks/queries/useEventDataProperties.ts
Normal file
20
src/components/hooks/queries/useEventDataProperties.ts
Normal 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;
|
||||
23
src/components/hooks/queries/useEventDataValues.ts
Normal file
23
src/components/hooks/queries/useEventDataValues.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
18
src/components/hooks/queries/useRevenueValues.ts
Normal file
18
src/components/hooks/queries/useRevenueValues.ts
Normal 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;
|
||||
21
src/components/hooks/queries/useSessionActivity.ts
Normal file
21
src/components/hooks/queries/useSessionActivity.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
12
src/components/hooks/queries/useSessionData.ts
Normal file
12
src/components/hooks/queries/useSessionData.ts
Normal 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
20
src/components/hooks/queries/useSessionDataProperties.ts
Normal file
20
src/components/hooks/queries/useSessionDataProperties.ts
Normal 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;
|
||||
21
src/components/hooks/queries/useSessionDataValues.ts
Normal file
21
src/components/hooks/queries/useSessionDataValues.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import useApi from './useApi';
|
||||
import { useApi } from '../useApi';
|
||||
|
||||
export function useTeam(teamId: string) {
|
||||
const { get, useQuery } = useApi();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
20
src/components/hooks/queries/useWebsiteEventsSeries.ts
Normal file
20
src/components/hooks/queries/useWebsiteEventsSeries.ts
Normal 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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
14
src/components/hooks/queries/useWebsiteSession.ts
Normal file
14
src/components/hooks/queries/useWebsiteSession.ts
Normal 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;
|
||||
16
src/components/hooks/queries/useWebsiteSessionStats.ts
Normal file
16
src/components/hooks/queries/useWebsiteSessionStats.ts
Normal 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;
|
||||
24
src/components/hooks/queries/useWebsiteSessions.ts
Normal file
24
src/components/hooks/queries/useWebsiteSessions.ts
Normal 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;
|
||||
24
src/components/hooks/queries/useWebsiteSessionsWeekly.ts
Normal file
24
src/components/hooks/queries/useWebsiteSessionsWeekly.ts
Normal 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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
46
src/components/hooks/useFilterParams.ts
Normal file
46
src/components/hooks/useFilterParams.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
19
src/components/hooks/useRegionNames.ts
Normal file
19
src/components/hooks/useRegionNames.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
3
src/components/input/DateFilter.module.css
Normal file
3
src/components/input/DateFilter.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.dropdown span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
})}
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
min-height: calc(100vh - 60px);
|
||||
min-height: calc(100dvh - 60px);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
26
src/components/metrics/ChangeLabel.module.css
Normal file
26
src/components/metrics/ChangeLabel.module.css
Normal 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);
|
||||
}
|
||||
46
src/components/metrics/ChangeLabel.tsx
Normal file
46
src/components/metrics/ChangeLabel.tsx
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
35
src/components/metrics/HostsTable.tsx
Normal file
35
src/components/metrics/HostsTable.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
30
src/components/metrics/TagsTable.tsx
Normal file
30
src/components/metrics/TagsTable.tsx
Normal 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;
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user