Moved code into src folder. Added build for component library.

This commit is contained in:
Mike Cao 2023-08-21 02:06:09 -07:00
parent 7a7233ead4
commit ede658771e
490 changed files with 749 additions and 442 deletions

View 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;

View 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;
}

View 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;

View 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;
}

View 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;

View 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;

View 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;

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;

View 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;

View file

@ -0,0 +1,3 @@
.chart {
display: flex;
}

View 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;

View 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;

View 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);
}

View 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;

View 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;

View 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);
}

View 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;

View 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: '+';
}

View 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;

View 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);
}
}

View 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;

View 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;
}
}

View 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;

View 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;

View 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;

View 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;

View 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;
}

View 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;

View 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;

View 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;

View 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;