mirror of
https://github.com/umami-software/umami.git
synced 2026-02-10 07:37:11 +01:00
Merge branch 'dev' into search-formatted-metrics
This commit is contained in:
commit
4ab8b1ff91
807 changed files with 45367 additions and 8474 deletions
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue