mirror of
https://github.com/umami-software/umami.git
synced 2026-02-07 14:17:13 +01:00
Moved code into src folder. Added build for component library.
This commit is contained in:
parent
7a7233ead4
commit
ede658771e
490 changed files with 749 additions and 442 deletions
38
src/components/metrics/ActiveUsers.js
Normal file
38
src/components/metrics/ActiveUsers.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useMemo } from 'react';
|
||||
import { StatusLight } from 'react-basics';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import styles from './ActiveUsers.module.css';
|
||||
|
||||
export function ActiveUsers({ websiteId, value, refetchInterval = 60000 }) {
|
||||
const { formatMessage, messages } = useMessages();
|
||||
const { get, useQuery } = useApi();
|
||||
const { data } = useQuery(
|
||||
['websites:active', websiteId],
|
||||
() => get(`/websites/${websiteId}/active`),
|
||||
{
|
||||
refetchInterval,
|
||||
enabled: !!websiteId,
|
||||
},
|
||||
);
|
||||
|
||||
const count = useMemo(() => {
|
||||
if (websiteId) {
|
||||
return data?.[0]?.x || 0;
|
||||
}
|
||||
|
||||
return value !== undefined ? value : 0;
|
||||
}, [data, value, websiteId]);
|
||||
|
||||
if (count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusLight className={styles.container} variant="success">
|
||||
<div className={styles.text}>{formatMessage(messages.activeUsers, { x: count })}</div>
|
||||
</StatusLight>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActiveUsers;
|
||||
17
src/components/metrics/ActiveUsers.module.css
Normal file
17
src/components/metrics/ActiveUsers.module.css
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 600;
|
||||
margin-right: 4px;
|
||||
}
|
||||
163
src/components/metrics/BarChart.js
Normal file
163
src/components/metrics/BarChart.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Loading } from 'react-basics';
|
||||
import classNames from 'classnames';
|
||||
import Chart from 'chart.js/auto';
|
||||
import HoverTooltip from 'components/common/HoverTooltip';
|
||||
import Legend from 'components/metrics/Legend';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
import useTheme from 'components/hooks/useTheme';
|
||||
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
|
||||
import { renderNumberLabels } from 'lib/charts';
|
||||
import styles from './BarChart.module.css';
|
||||
|
||||
export function BarChart({
|
||||
datasets,
|
||||
unit,
|
||||
animationDuration = DEFAULT_ANIMATION_DURATION,
|
||||
stacked = false,
|
||||
loading = false,
|
||||
renderXLabel,
|
||||
renderYLabel,
|
||||
XAxisType = 'time',
|
||||
YAxisType = 'linear',
|
||||
renderTooltipPopup,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
className,
|
||||
}) {
|
||||
const canvas = useRef();
|
||||
const chart = useRef(null);
|
||||
const [tooltip, setTooltipPopup] = useState(null);
|
||||
const { locale } = useLocale();
|
||||
const { theme, colors } = useTheme();
|
||||
|
||||
const getOptions = useCallback(() => {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: {
|
||||
duration: animationDuration,
|
||||
resize: {
|
||||
duration: 0,
|
||||
},
|
||||
active: {
|
||||
duration: 0,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
external: renderTooltipPopup ? renderTooltipPopup.bind(null, setTooltipPopup) : undefined,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: XAxisType,
|
||||
stacked: true,
|
||||
time: {
|
||||
unit,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
border: {
|
||||
color: colors.chart.line,
|
||||
},
|
||||
ticks: {
|
||||
color: colors.chart.text,
|
||||
autoSkip: false,
|
||||
maxRotation: 0,
|
||||
callback: renderXLabel,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
type: YAxisType,
|
||||
min: 0,
|
||||
beginAtZero: true,
|
||||
stacked,
|
||||
grid: {
|
||||
color: colors.chart.line,
|
||||
},
|
||||
border: {
|
||||
color: colors.chart.line,
|
||||
},
|
||||
ticks: {
|
||||
color: colors.text,
|
||||
callback: renderYLabel || renderNumberLabels,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [
|
||||
animationDuration,
|
||||
renderTooltipPopup,
|
||||
renderXLabel,
|
||||
XAxisType,
|
||||
YAxisType,
|
||||
stacked,
|
||||
colors,
|
||||
unit,
|
||||
locale,
|
||||
]);
|
||||
|
||||
const createChart = () => {
|
||||
Chart.defaults.font.family = 'Inter';
|
||||
|
||||
const options = getOptions();
|
||||
|
||||
chart.current = new Chart(canvas.current, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
datasets,
|
||||
},
|
||||
options,
|
||||
});
|
||||
|
||||
onCreate?.(chart.current);
|
||||
};
|
||||
|
||||
const updateChart = () => {
|
||||
setTooltipPopup(null);
|
||||
|
||||
datasets.forEach((dataset, index) => {
|
||||
chart.current.data.datasets[index].data = dataset.data;
|
||||
chart.current.data.datasets[index].label = dataset.label;
|
||||
});
|
||||
|
||||
chart.current.options = getOptions();
|
||||
|
||||
onUpdate?.(chart.current);
|
||||
|
||||
chart.current.update();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (datasets) {
|
||||
if (!chart.current) {
|
||||
createChart();
|
||||
} else {
|
||||
updateChart();
|
||||
}
|
||||
}
|
||||
}, [datasets, unit, theme, animationDuration, locale]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames(styles.chart, className)}>
|
||||
{loading && <Loading position="page" icon="dots" />}
|
||||
<canvas ref={canvas} />
|
||||
</div>
|
||||
<Legend chart={chart.current} />
|
||||
{tooltip && (
|
||||
<HoverTooltip>
|
||||
<div className={styles.tooltip}>{tooltip}</div>
|
||||
</HoverTooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BarChart;
|
||||
15
src/components/metrics/BarChart.module.css
Normal file
15
src/components/metrics/BarChart.module.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.chart {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tooltip .value {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
37
src/components/metrics/BrowsersTable.js
Normal file
37
src/components/metrics/BrowsersTable.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import MetricsTable from 'components/metrics/MetricsTable';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useFormat from 'components/hooks/useFormat';
|
||||
|
||||
export function BrowsersTable({ websiteId, ...props }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { basePath } = useRouter();
|
||||
const { formatBrowser } = useFormat();
|
||||
|
||||
function renderLink({ x: browser }) {
|
||||
return (
|
||||
<FilterLink id="browser" value={browser} label={formatBrowser(browser)}>
|
||||
<img
|
||||
src={`${basePath}/images/browsers/${browser || 'unknown'}.png`}
|
||||
alt={browser}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
</FilterLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.browsers)}
|
||||
type="browser"
|
||||
metric={formatMessage(labels.visitors)}
|
||||
websiteId={websiteId}
|
||||
renderLabel={renderLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default BrowsersTable;
|
||||
32
src/components/metrics/CitiesTable.js
Normal file
32
src/components/metrics/CitiesTable.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import MetricsTable from './MetricsTable';
|
||||
import { emptyFilter } from 'lib/filters';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function CitiesTable({ websiteId, ...props }) {
|
||||
const { locale } = useLocale();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
function renderLink({ x }) {
|
||||
return (
|
||||
<div className={locale}>
|
||||
<FilterLink id="city" value={x} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.cities)}
|
||||
type="city"
|
||||
metric={formatMessage(labels.visitors)}
|
||||
websiteId={websiteId}
|
||||
dataFilter={emptyFilter}
|
||||
renderLabel={renderLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default CitiesTable;
|
||||
39
src/components/metrics/CountriesTable.js
Normal file
39
src/components/metrics/CountriesTable.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import useCountryNames from 'components/hooks/useCountryNames';
|
||||
import { useLocale, useMessages, useFormat } from 'components/hooks';
|
||||
import MetricsTable from './MetricsTable';
|
||||
|
||||
export function CountriesTable({ websiteId, ...props }) {
|
||||
const { locale } = useLocale();
|
||||
const countryNames = useCountryNames(locale);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { basePath } = useRouter();
|
||||
const { formatCountry } = useFormat();
|
||||
|
||||
function renderLink({ x: code }) {
|
||||
return (
|
||||
<FilterLink
|
||||
id="country"
|
||||
className={locale}
|
||||
value={countryNames[code] && code}
|
||||
label={formatCountry(code)}
|
||||
>
|
||||
<img src={`${basePath}/images/flags/${code?.toLowerCase() || 'xx'}.png`} alt={code} />
|
||||
</FilterLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.countries)}
|
||||
type="country"
|
||||
metric={formatMessage(labels.visitors)}
|
||||
websiteId={websiteId}
|
||||
renderLabel={renderLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default CountriesTable;
|
||||
105
src/components/metrics/DataTable.js
Normal file
105
src/components/metrics/DataTable.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useState } from 'react';
|
||||
import useMeasure from 'react-use-measure';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import { useSpring, animated, config } from 'react-spring';
|
||||
import classNames from 'classnames';
|
||||
import Empty from 'components/common/Empty';
|
||||
import { formatNumber, formatLongNumber } from 'lib/format';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import styles from './DataTable.module.css';
|
||||
|
||||
export function DataTable({
|
||||
data = [],
|
||||
title,
|
||||
metric,
|
||||
className,
|
||||
renderLabel,
|
||||
animate = true,
|
||||
virtualize = false,
|
||||
showPercentage = true,
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [ref, bounds] = useMeasure();
|
||||
const [format, setFormat] = useState(true);
|
||||
const formatFunc = format ? formatLongNumber : formatNumber;
|
||||
|
||||
const handleSetFormat = () => setFormat(state => !state);
|
||||
|
||||
const getRow = row => {
|
||||
const { x: label, y: value, z: percent } = row;
|
||||
|
||||
return (
|
||||
<AnimatedRow
|
||||
key={label}
|
||||
label={renderLabel ? renderLabel(row) : label ?? formatMessage(labels.unknown)}
|
||||
value={value}
|
||||
percent={percent}
|
||||
animate={animate && !virtualize}
|
||||
format={formatFunc}
|
||||
onClick={handleSetFormat}
|
||||
showPercentage={showPercentage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Row = ({ index, style }) => {
|
||||
return <div style={style}>{getRow(data[index])}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.table, className)}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<div className={styles.metric} onClick={handleSetFormat}>
|
||||
{metric}
|
||||
</div>
|
||||
</div>
|
||||
<div ref={ref} className={styles.body}>
|
||||
{data?.length === 0 && <Empty />}
|
||||
{virtualize && data.length > 0 ? (
|
||||
<FixedSizeList height={bounds.height} itemCount={data.length} itemSize={30}>
|
||||
{Row}
|
||||
</FixedSizeList>
|
||||
) : (
|
||||
data.map(row => getRow(row))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AnimatedRow = ({
|
||||
label,
|
||||
value = 0,
|
||||
percent,
|
||||
animate,
|
||||
format,
|
||||
onClick,
|
||||
showPercentage = true,
|
||||
}) => {
|
||||
const props = useSpring({
|
||||
width: percent,
|
||||
y: value,
|
||||
from: { width: 0, y: 0 },
|
||||
config: animate ? config.default : { duration: 0 },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<div className={styles.label}>{label}</div>
|
||||
<div className={styles.value} onClick={onClick}>
|
||||
<animated.div className={styles.value}>{props.y?.to(format)}</animated.div>
|
||||
</div>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataTable;
|
||||
100
src/components/metrics/DataTable.module.css
Normal file
100
src/components/metrics/DataTable.module.css
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
.table {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: fit-content(100%) auto;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.body {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metric {
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
width: 100px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
.label a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.label a:hover {
|
||||
color: var(--primary400);
|
||||
}
|
||||
|
||||
.label:empty {
|
||||
color: #b3b3b3;
|
||||
}
|
||||
|
||||
.label:empty:before {
|
||||
content: 'Unknown';
|
||||
}
|
||||
|
||||
.value {
|
||||
width: 50px;
|
||||
text-align: end;
|
||||
margin-inline-end: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.percent {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
color: var(--base600);
|
||||
border-left: 1px solid var(--base600);
|
||||
padding-inline-start: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 30px;
|
||||
opacity: 0.1;
|
||||
background: var(--primary400);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.body {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
86
src/components/metrics/DatePickerForm.js
Normal file
86
src/components/metrics/DatePickerForm.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { useState } from 'react';
|
||||
import { Button, ButtonGroup, Calendar } from 'react-basics';
|
||||
import { isAfter, isBefore, isSameDay } from 'date-fns';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
import { getDateLocale } from 'lib/lang';
|
||||
import { FILTER_DAY, FILTER_RANGE } from 'lib/constants';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import styles from './DatePickerForm.module.css';
|
||||
|
||||
export function DatePickerForm({
|
||||
startDate: defaultStartDate,
|
||||
endDate: defaultEndDate,
|
||||
minDate,
|
||||
maxDate,
|
||||
onChange,
|
||||
onClose,
|
||||
}) {
|
||||
const [selected, setSelected] = useState(
|
||||
isSameDay(defaultStartDate, defaultEndDate) ? FILTER_DAY : FILTER_RANGE,
|
||||
);
|
||||
const [singleDate, setSingleDate] = useState(defaultStartDate);
|
||||
const [startDate, setStartDate] = useState(defaultStartDate);
|
||||
const [endDate, setEndDate] = useState(defaultEndDate);
|
||||
const { locale } = useLocale();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const disabled =
|
||||
selected === FILTER_DAY
|
||||
? isAfter(minDate, singleDate) && isBefore(maxDate, singleDate)
|
||||
: isAfter(startDate, endDate);
|
||||
|
||||
const handleSave = () => {
|
||||
if (selected === FILTER_DAY) {
|
||||
onChange(`range:${singleDate.getTime()}:${singleDate.getTime()}`);
|
||||
} else {
|
||||
onChange(`range:${startDate.getTime()}:${endDate.getTime()}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filter}>
|
||||
<ButtonGroup selectedKey={selected} onSelect={setSelected}>
|
||||
<Button key={FILTER_DAY}>{formatMessage(labels.singleDay)}</Button>
|
||||
<Button key={FILTER_RANGE}>{formatMessage(labels.dateRange)}</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<div className={styles.calendars}>
|
||||
{selected === FILTER_DAY && (
|
||||
<Calendar
|
||||
date={singleDate}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
onChange={setSingleDate}
|
||||
/>
|
||||
)}
|
||||
{selected === FILTER_RANGE && (
|
||||
<>
|
||||
<Calendar
|
||||
date={startDate}
|
||||
minDate={minDate}
|
||||
maxDate={endDate}
|
||||
locale={getDateLocale(locale)}
|
||||
onChange={setStartDate}
|
||||
/>
|
||||
<Calendar
|
||||
date={endDate}
|
||||
minDate={startDate}
|
||||
maxDate={maxDate}
|
||||
locale={getDateLocale(locale)}
|
||||
onChange={setEndDate}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Button variant="primary" onClick={handleSave} disabled={disabled}>
|
||||
{formatMessage(labels.save)}
|
||||
</Button>
|
||||
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DatePickerForm;
|
||||
48
src/components/metrics/DatePickerForm.module.css
Normal file
48
src/components/metrics/DatePickerForm.module.css
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.calendars {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.calendars > div {
|
||||
width: 380px;
|
||||
}
|
||||
|
||||
.calendars > div + div {
|
||||
margin-left: 20px;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--base300);
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.calendars {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.calendars > div + div {
|
||||
padding: 0;
|
||||
margin-left: 0;
|
||||
margin-top: 20px;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
37
src/components/metrics/DevicesTable.js
Normal file
37
src/components/metrics/DevicesTable.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import MetricsTable from './MetricsTable';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useFormat } from 'components/hooks';
|
||||
|
||||
export function DevicesTable({ websiteId, ...props }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { basePath } = useRouter();
|
||||
const { formatDevice } = useFormat();
|
||||
|
||||
function renderLink({ x: device }) {
|
||||
return (
|
||||
<FilterLink id="device" value={labels[device] && device} label={formatDevice(device)}>
|
||||
<img
|
||||
src={`${basePath}/images/device/${device?.toLowerCase() || 'unknown'}.png`}
|
||||
alt={device}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
</FilterLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.devices)}
|
||||
type="device"
|
||||
metric={formatMessage(labels.visitors)}
|
||||
websiteId={websiteId}
|
||||
renderLabel={renderLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default DevicesTable;
|
||||
80
src/components/metrics/EventsChart.js
Normal file
80
src/components/metrics/EventsChart.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Loading } from 'react-basics';
|
||||
import { colord } from 'colord';
|
||||
import BarChart from './BarChart';
|
||||
import { getDateArray } from 'lib/date';
|
||||
import { useApi, useLocale, useDateRange, useTimezone, usePageQuery } from 'components/hooks';
|
||||
import { EVENT_COLORS } from 'lib/constants';
|
||||
import { renderDateLabels, renderStatusTooltipPopup } from 'lib/charts';
|
||||
|
||||
export function EventsChart({ websiteId, className, token }) {
|
||||
const { get, useQuery } = useApi();
|
||||
const [{ startDate, endDate, unit, modified }] = useDateRange(websiteId);
|
||||
const { locale } = useLocale();
|
||||
const [timezone] = useTimezone();
|
||||
const {
|
||||
query: { url, eventName },
|
||||
} = usePageQuery();
|
||||
|
||||
const { data, isLoading } = useQuery(['events', websiteId, modified, eventName], () =>
|
||||
get(`/websites/${websiteId}/events`, {
|
||||
startAt: +startDate,
|
||||
endAt: +endDate,
|
||||
unit,
|
||||
timezone,
|
||||
url,
|
||||
eventName,
|
||||
token,
|
||||
}),
|
||||
);
|
||||
|
||||
const datasets = useMemo(() => {
|
||||
if (!data) return [];
|
||||
if (isLoading) return data;
|
||||
|
||||
const map = data.reduce((obj, { x, t, y }) => {
|
||||
if (!obj[x]) {
|
||||
obj[x] = [];
|
||||
}
|
||||
|
||||
obj[x].push({ x: t, y });
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
Object.keys(map).forEach(key => {
|
||||
map[key] = getDateArray(map[key], startDate, endDate, unit);
|
||||
});
|
||||
|
||||
return Object.keys(map).map((key, index) => {
|
||||
const color = colord(EVENT_COLORS[index % EVENT_COLORS.length]);
|
||||
return {
|
||||
label: key,
|
||||
data: map[key],
|
||||
lineTension: 0,
|
||||
backgroundColor: color.alpha(0.6).toRgbString(),
|
||||
borderColor: color.alpha(0.7).toRgbString(),
|
||||
borderWidth: 1,
|
||||
};
|
||||
});
|
||||
}, [data, isLoading, startDate, endDate, unit]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading icon="dots" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
className={className}
|
||||
datasets={datasets}
|
||||
unit={unit}
|
||||
height={300}
|
||||
loading={isLoading}
|
||||
stacked
|
||||
renderXLabel={renderDateLabels(unit, locale)}
|
||||
renderTooltipPopup={renderStatusTooltipPopup(unit, locale)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventsChart;
|
||||
3
src/components/metrics/EventsChart.module.css
Normal file
3
src/components/metrics/EventsChart.module.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.chart {
|
||||
display: flex;
|
||||
}
|
||||
23
src/components/metrics/EventsTable.js
Normal file
23
src/components/metrics/EventsTable.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import MetricsTable from './MetricsTable';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function EventsTable({ websiteId, ...props }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
function handleDataLoad(data) {
|
||||
props.onDataLoad?.(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.events)}
|
||||
type="event"
|
||||
metric={formatMessage(labels.actions)}
|
||||
websiteId={websiteId}
|
||||
onDataLoad={handleDataLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventsTable;
|
||||
54
src/components/metrics/FilterTags.js
Normal file
54
src/components/metrics/FilterTags.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { safeDecodeURI } from 'next-basics';
|
||||
import { Button, Icon, Icons, Text } from 'react-basics';
|
||||
import usePageQuery from 'components/hooks/usePageQuery';
|
||||
import styles from './FilterTags.module.css';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function FilterTags({ params }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const {
|
||||
router,
|
||||
resolveUrl,
|
||||
query: { view },
|
||||
} = usePageQuery();
|
||||
|
||||
if (Object.keys(params).filter(key => params[key]).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleCloseFilter(param) {
|
||||
if (!param) {
|
||||
router.push(resolveUrl({ view }, true));
|
||||
} else {
|
||||
router.push(resolveUrl({ [param]: undefined }));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.filters}>
|
||||
{Object.keys(params).map(key => {
|
||||
if (!params[key]) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={key} className={styles.tag} onClick={() => handleCloseFilter(key)}>
|
||||
<Text>
|
||||
<b>{`${key}`}</b> = {`${safeDecodeURI(params[key])}`}
|
||||
</Text>
|
||||
<Icon>
|
||||
<Icons.Close />
|
||||
</Icon>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button size="sm" variant="quiet" onClick={() => handleCloseFilter()}>
|
||||
<Icon>
|
||||
<Icons.Close />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.clearAll)}</Text>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterTags;
|
||||
22
src/components/metrics/FilterTags.module.css
Normal file
22
src/components/metrics/FilterTags.module.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: var(--font-size-sm);
|
||||
border: 1px solid var(--base600);
|
||||
border-radius: var(--border-radius);
|
||||
line-height: 30px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
background: var(--base75);
|
||||
}
|
||||
29
src/components/metrics/LanguagesTable.js
Normal file
29
src/components/metrics/LanguagesTable.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import MetricsTable from './MetricsTable';
|
||||
import { percentFilter } from 'lib/filters';
|
||||
import useLanguageNames from 'components/hooks/useLanguageNames';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function LanguagesTable({ websiteId, onDataLoad, ...props }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { locale } = useLocale();
|
||||
const languageNames = useLanguageNames(locale);
|
||||
|
||||
const renderLabel = ({ x }) => {
|
||||
return <div className={locale}>{languageNames[x?.split('-')[0]] ?? x}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.languages)}
|
||||
type="language"
|
||||
metric={formatMessage(labels.visitors)}
|
||||
websiteId={websiteId}
|
||||
onDataLoad={data => onDataLoad?.(percentFilter(data))}
|
||||
renderLabel={renderLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguagesTable;
|
||||
52
src/components/metrics/Legend.js
Normal file
52
src/components/metrics/Legend.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { useEffect } from 'react';
|
||||
import { StatusLight } from 'react-basics';
|
||||
import { colord } from 'colord';
|
||||
import classNames from 'classnames';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
import useForceUpdate from 'components/hooks/useForceUpdate';
|
||||
import styles from './Legend.module.css';
|
||||
|
||||
export function Legend({ chart }) {
|
||||
const { locale } = useLocale();
|
||||
const forceUpdate = useForceUpdate();
|
||||
|
||||
const handleClick = index => {
|
||||
const meta = chart.getDatasetMeta(index);
|
||||
|
||||
meta.hidden = meta.hidden === null ? !chart.data.datasets[index].hidden : null;
|
||||
|
||||
chart.update();
|
||||
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
forceUpdate();
|
||||
}, [locale, forceUpdate]);
|
||||
|
||||
if (!chart?.legend?.legendItems.find(({ text }) => text)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.legend}>
|
||||
{chart.legend.legendItems.map(({ text, fillStyle, datasetIndex, hidden }) => {
|
||||
const color = colord(fillStyle);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={text}
|
||||
className={classNames(styles.label, { [styles.hidden]: hidden })}
|
||||
onClick={() => handleClick(datasetIndex)}
|
||||
>
|
||||
<StatusLight color={color.alpha(color.alpha() + 0.2).toHex()}>
|
||||
<span className={locale}>{text}</span>
|
||||
</StatusLight>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Legend;
|
||||
21
src/components/metrics/Legend.module.css
Normal file
21
src/components/metrics/Legend.module.css
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
.legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.label + .label {
|
||||
margin-inline-start: 20px;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
color: var(--base400);
|
||||
}
|
||||
39
src/components/metrics/MetricCard.js
Normal file
39
src/components/metrics/MetricCard.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import classNames from 'classnames';
|
||||
import { useSpring, animated } from 'react-spring';
|
||||
import { formatNumber } from 'lib/format';
|
||||
import styles from './MetricCard.module.css';
|
||||
|
||||
export const MetricCard = ({
|
||||
value = 0,
|
||||
change = 0,
|
||||
label,
|
||||
reverseColors = false,
|
||||
format = formatNumber,
|
||||
hideComparison = false,
|
||||
className,
|
||||
}) => {
|
||||
const props = useSpring({ x: Number(value) || 0, from: { x: 0 } });
|
||||
const changeProps = useSpring({ x: Number(change) || 0, from: { x: 0 } });
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.card, className)}>
|
||||
<animated.div className={styles.value}>{props.x.to(x => format(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,
|
||||
})}
|
||||
>
|
||||
{changeProps.x.to(x => format(x))}
|
||||
</animated.span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricCard;
|
||||
46
src/components/metrics/MetricCard.module.css
Normal file
46
src/components/metrics/MetricCard.module.css
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 90px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
gap: 10px;
|
||||
white-space: nowrap;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.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: '+';
|
||||
}
|
||||
29
src/components/metrics/MetricsBar.js
Normal file
29
src/components/metrics/MetricsBar.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useState } from 'react';
|
||||
import { Loading, cloneChildren } from 'react-basics';
|
||||
import ErrorMessage from 'components/common/ErrorMessage';
|
||||
import styles from './MetricsBar.module.css';
|
||||
import { formatLongNumber, formatNumber } from 'lib/format';
|
||||
|
||||
export function MetricsBar({ children, isLoading, isFetched, error }) {
|
||||
const [format, setFormat] = useState(true);
|
||||
|
||||
const formatFunc = format
|
||||
? n => (n >= 0 ? formatLongNumber(n) : `-${formatLongNumber(Math.abs(n))}`)
|
||||
: formatNumber;
|
||||
|
||||
const handleSetFormat = () => {
|
||||
setFormat(state => !state);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.bar} onClick={handleSetFormat}>
|
||||
{isLoading && !isFetched && <Loading icon="dots" />}
|
||||
{error && <ErrorMessage />}
|
||||
{cloneChildren(children, child => {
|
||||
return { format: child.props.format || formatFunc };
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetricsBar;
|
||||
18
src/components/metrics/MetricsBar.module.css
Normal file
18
src/components/metrics/MetricsBar.module.css
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
.bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
cursor: pointer;
|
||||
min-height: 110px;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.card {
|
||||
flex-basis: calc(50% - 20px);
|
||||
}
|
||||
}
|
||||
124
src/components/metrics/MetricsTable.js
Normal file
124
src/components/metrics/MetricsTable.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { useMemo } from 'react';
|
||||
import { Loading, Icon, Text, Button } from 'react-basics';
|
||||
import Link from 'next/link';
|
||||
import firstBy from 'thenby';
|
||||
import classNames from 'classnames';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import { percentFilter } from 'lib/filters';
|
||||
import useDateRange from 'components/hooks/useDateRange';
|
||||
import usePageQuery from 'components/hooks/usePageQuery';
|
||||
import ErrorMessage from 'components/common/ErrorMessage';
|
||||
import DataTable from './DataTable';
|
||||
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
|
||||
import Icons from 'components/icons';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import styles from './MetricsTable.module.css';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
|
||||
export function MetricsTable({
|
||||
websiteId,
|
||||
type,
|
||||
className,
|
||||
dataFilter,
|
||||
filterOptions,
|
||||
limit,
|
||||
onDataLoad,
|
||||
delay = null,
|
||||
...props
|
||||
}) {
|
||||
const [{ startDate, endDate, modified }] = useDateRange(websiteId);
|
||||
const {
|
||||
resolveUrl,
|
||||
router,
|
||||
query: { url, referrer, title, os, browser, device, country, region, city },
|
||||
} = usePageQuery();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { get, useQuery } = useApi();
|
||||
const { dir } = useLocale();
|
||||
|
||||
const { data, isLoading, isFetched, error } = useQuery(
|
||||
[
|
||||
'websites:metrics',
|
||||
{
|
||||
websiteId,
|
||||
type,
|
||||
modified,
|
||||
url,
|
||||
referrer,
|
||||
os,
|
||||
title,
|
||||
browser,
|
||||
device,
|
||||
country,
|
||||
region,
|
||||
city,
|
||||
},
|
||||
],
|
||||
() =>
|
||||
get(`/websites/${websiteId}/metrics`, {
|
||||
type,
|
||||
startAt: +startDate,
|
||||
endAt: +endDate,
|
||||
url,
|
||||
title,
|
||||
referrer,
|
||||
os,
|
||||
browser,
|
||||
device,
|
||||
country,
|
||||
region,
|
||||
city,
|
||||
}),
|
||||
{ onSuccess: onDataLoad, retryDelay: delay || DEFAULT_ANIMATION_DURATION },
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (data) {
|
||||
let items = data;
|
||||
|
||||
if (dataFilter) {
|
||||
if (Array.isArray(dataFilter)) {
|
||||
items = dataFilter.reduce((arr, filter) => {
|
||||
return filter(arr);
|
||||
}, items);
|
||||
} else {
|
||||
items = dataFilter(data);
|
||||
}
|
||||
}
|
||||
|
||||
items = percentFilter(items);
|
||||
|
||||
if (limit) {
|
||||
items = items.filter((e, i) => i < limit);
|
||||
}
|
||||
if (filterOptions?.sort === false) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.sort(firstBy('y', -1).thenBy('x'));
|
||||
}
|
||||
return [];
|
||||
}, [data, error, dataFilter, filterOptions, limit]);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.container, className)}>
|
||||
{!data && isLoading && !isFetched && <Loading icon="dots" />}
|
||||
{error && <ErrorMessage />}
|
||||
{data && !error && <DataTable {...props} data={filteredData} className={className} />}
|
||||
<div className={styles.footer}>
|
||||
{data && !error && limit && (
|
||||
<Link href={router.pathname} as={resolveUrl({ view: type })}>
|
||||
<Button variant="quiet">
|
||||
<Text>{formatMessage(labels.more)}</Text>
|
||||
<Icon size="sm" rotate={dir === 'rtl' ? 180 : 0}>
|
||||
<Icons.ArrowRight />
|
||||
</Icon>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MetricsTable;
|
||||
19
src/components/metrics/MetricsTable.module.css
Normal file
19
src/components/metrics/MetricsTable.module.css
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
.container {
|
||||
position: relative;
|
||||
min-height: 430px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.container {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
37
src/components/metrics/OSTable.js
Normal file
37
src/components/metrics/OSTable.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import MetricsTable from './MetricsTable';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export function OSTable({ websiteId, ...props }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { basePath } = useRouter();
|
||||
|
||||
function renderLink({ x: os }) {
|
||||
return (
|
||||
<FilterLink id="os" value={os}>
|
||||
<img
|
||||
src={`${basePath}/images/os/${
|
||||
os?.toLowerCase().replaceAll(/[^\w]+/g, '-') || 'unknown'
|
||||
}.png`}
|
||||
alt={os}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
</FilterLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
websiteId={websiteId}
|
||||
title={formatMessage(labels.os)}
|
||||
metric={formatMessage(labels.visitors)}
|
||||
renderLabel={renderLink}
|
||||
type="os"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default OSTable;
|
||||
51
src/components/metrics/PagesTable.js
Normal file
51
src/components/metrics/PagesTable.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import FilterLink from 'components/common/FilterLink';
|
||||
import FilterButtons from 'components/common/FilterButtons';
|
||||
import MetricsTable from './MetricsTable';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import usePageQuery from 'components/hooks/usePageQuery';
|
||||
import { emptyFilter } from 'lib/filters';
|
||||
|
||||
export function PagesTable({ websiteId, showFilters, ...props }) {
|
||||
const {
|
||||
router,
|
||||
resolveUrl,
|
||||
query: { view = 'url' },
|
||||
} = usePageQuery();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const handleSelect = key => {
|
||||
router.push(resolveUrl({ view: key }), null, { shallow: true });
|
||||
};
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: 'URL',
|
||||
key: 'url',
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.title),
|
||||
key: 'title',
|
||||
},
|
||||
];
|
||||
|
||||
const renderLink = ({ x }) => {
|
||||
return <FilterLink id={view} value={x} label={!x && formatMessage(labels.none)} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showFilters && <FilterButtons items={buttons} selectedKey={view} onSelect={handleSelect} />}
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.pages)}
|
||||
type={view}
|
||||
metric={formatMessage(labels.views)}
|
||||
websiteId={websiteId}
|
||||
dataFilter={emptyFilter}
|
||||
renderLabel={renderLink}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PagesTable;
|
||||
43
src/components/metrics/PageviewsChart.js
Normal file
43
src/components/metrics/PageviewsChart.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useMemo } from 'react';
|
||||
import BarChart from './BarChart';
|
||||
import { useLocale, useTheme, useMessages } from 'components/hooks';
|
||||
import { renderDateLabels, renderStatusTooltipPopup } from 'lib/charts';
|
||||
|
||||
export function PageviewsChart({ websiteId, data, unit, loading, ...props }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { colors } = useTheme();
|
||||
const { locale } = useLocale();
|
||||
|
||||
const datasets = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
label: formatMessage(labels.uniqueVisitors),
|
||||
data: data.sessions,
|
||||
borderWidth: 1,
|
||||
...colors.chart.visitors,
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.pageViews),
|
||||
data: data.pageviews,
|
||||
borderWidth: 1,
|
||||
...colors.chart.views,
|
||||
},
|
||||
];
|
||||
}, [data, colors, formatMessage, labels]);
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
{...props}
|
||||
key={websiteId}
|
||||
datasets={datasets}
|
||||
unit={unit}
|
||||
loading={loading}
|
||||
renderXLabel={renderDateLabels(unit, locale)}
|
||||
renderTooltipPopup={renderStatusTooltipPopup(unit, locale)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageviewsChart;
|
||||
53
src/components/metrics/QueryParametersTable.js
Normal file
53
src/components/metrics/QueryParametersTable.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useState } from 'react';
|
||||
import { safeDecodeURI } from 'next-basics';
|
||||
import FilterButtons from 'components/common/FilterButtons';
|
||||
import { emptyFilter, paramFilter } from 'lib/filters';
|
||||
import { FILTER_RAW, FILTER_COMBINED } from 'lib/constants';
|
||||
import MetricsTable from './MetricsTable';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import styles from './QueryParametersTable.module.css';
|
||||
|
||||
const filters = {
|
||||
[FILTER_RAW]: emptyFilter,
|
||||
[FILTER_COMBINED]: [emptyFilter, paramFilter],
|
||||
};
|
||||
|
||||
export function QueryParametersTable({ websiteId, showFilters, ...props }) {
|
||||
const [filter, setFilter] = useState(FILTER_COMBINED);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: formatMessage(labels.filterCombined),
|
||||
key: FILTER_COMBINED,
|
||||
},
|
||||
{ label: formatMessage(labels.filterRaw), key: FILTER_RAW },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{showFilters && <FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />}
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.query)}
|
||||
type="query"
|
||||
metric={formatMessage(labels.views)}
|
||||
websiteId={websiteId}
|
||||
dataFilter={filters[filter]}
|
||||
renderLabel={({ x, p, v }) =>
|
||||
filter === FILTER_RAW ? (
|
||||
x
|
||||
) : (
|
||||
<div className={styles.item}>
|
||||
<div className={styles.param}>{safeDecodeURI(p)}</div>
|
||||
<div className={styles.value}>{safeDecodeURI(v)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
delay={0}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryParametersTable;
|
||||
16
src/components/metrics/QueryParametersTable.module.css
Normal file
16
src/components/metrics/QueryParametersTable.module.css
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
.item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.param {
|
||||
padding: 0 8px;
|
||||
color: var(--primary400);
|
||||
background: var(--blue100);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.value {
|
||||
padding: 0 8px;
|
||||
}
|
||||
62
src/components/metrics/RealtimeChart.js
Normal file
62
src/components/metrics/RealtimeChart.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useMemo, useRef } from 'react';
|
||||
import { format, startOfMinute, subMinutes, isBefore } from 'date-fns';
|
||||
import PageviewsChart from './PageviewsChart';
|
||||
import { getDateArray } from 'lib/date';
|
||||
import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from 'lib/constants';
|
||||
|
||||
function mapData(data) {
|
||||
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 function RealtimeChart({ data, unit, ...props }) {
|
||||
const endDate = startOfMinute(new Date());
|
||||
const startDate = subMinutes(endDate, REALTIME_RANGE);
|
||||
const prevEndDate = useRef(endDate);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!data) {
|
||||
return { pageviews: [], sessions: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
pageviews: getDateArray(mapData(data.pageviews), startDate, endDate, unit),
|
||||
sessions: getDateArray(mapData(data.visitors), startDate, endDate, unit),
|
||||
};
|
||||
}, [data, startDate, endDate, unit]);
|
||||
|
||||
// Don't animate the bars shifting over because it looks weird
|
||||
const animationDuration = useMemo(() => {
|
||||
if (isBefore(prevEndDate.current, endDate)) {
|
||||
prevEndDate.current = endDate;
|
||||
return 0;
|
||||
}
|
||||
return DEFAULT_ANIMATION_DURATION;
|
||||
}, [endDate]);
|
||||
|
||||
return (
|
||||
<PageviewsChart
|
||||
{...props}
|
||||
height={200}
|
||||
unit={unit}
|
||||
data={chartData}
|
||||
animationDuration={animationDuration}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default RealtimeChart;
|
||||
33
src/components/metrics/ReferrersTable.js
Normal file
33
src/components/metrics/ReferrersTable.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import MetricsTable from './MetricsTable';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function ReferrersTable({ websiteId, ...props }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const renderLink = ({ x: referrer }) => {
|
||||
return (
|
||||
<FilterLink
|
||||
id="referrer"
|
||||
value={referrer}
|
||||
externalUrl={`https://${referrer}`}
|
||||
label={!referrer && formatMessage(labels.none)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.referrers)}
|
||||
type="referrer"
|
||||
metric={formatMessage(labels.views)}
|
||||
websiteId={websiteId}
|
||||
renderLabel={renderLink}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReferrersTable;
|
||||
44
src/components/metrics/RegionsTable.js
Normal file
44
src/components/metrics/RegionsTable.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import { emptyFilter } from 'lib/filters';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useCountryNames from 'components/hooks/useCountryNames';
|
||||
import MetricsTable from './MetricsTable';
|
||||
import regions from 'public/iso-3166-2.json';
|
||||
|
||||
export function RegionsTable({ websiteId, ...props }) {
|
||||
const { locale } = useLocale();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const countryNames = useCountryNames(locale);
|
||||
const { basePath } = useRouter();
|
||||
|
||||
const renderLabel = x => {
|
||||
return regions[x] ? `${regions[x]}, ${countryNames[x.split('-')[0]]}` : x;
|
||||
};
|
||||
|
||||
const renderLink = ({ x: code }) => {
|
||||
return (
|
||||
<FilterLink id="region" className={locale} value={code} label={renderLabel(code)}>
|
||||
<img
|
||||
src={`${basePath}/images/flags/${code?.split('-')?.[0]?.toLowerCase() || 'xx'}.png`}
|
||||
alt={code}
|
||||
/>
|
||||
</FilterLink>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.regions)}
|
||||
type="region"
|
||||
metric={formatMessage(labels.visitors)}
|
||||
websiteId={websiteId}
|
||||
dataFilter={emptyFilter}
|
||||
renderLabel={renderLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default RegionsTable;
|
||||
18
src/components/metrics/ScreenTable.js
Normal file
18
src/components/metrics/ScreenTable.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import MetricsTable from './MetricsTable';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function ScreenTable({ websiteId, ...props }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.screens)}
|
||||
type="screen"
|
||||
metric={formatMessage(labels.visitors)}
|
||||
websiteId={websiteId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScreenTable;
|
||||
Loading…
Add table
Add a link
Reference in a new issue