Merge branch 'dev' into hosts-support

This commit is contained in:
Mike Cao 2024-06-18 23:02:14 -07:00 committed by GitHub
commit d1559c3a98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
281 changed files with 7555 additions and 1973 deletions

View file

@ -26,7 +26,7 @@ export function BarChart(props: BarChartProps) {
stacked = false,
} = props;
const options = useMemo(() => {
const options: any = useMemo(() => {
return {
scales: {
x: {

View file

@ -21,7 +21,9 @@ export default function BarChartTooltip({ tooltip, unit }) {
return (
<Flexbox direction="column" gap={10}>
<div>{formatDate(new Date(dataPoints[0].raw.x), formats[unit], locale)}</div>
<div>
{formatDate(new Date(dataPoints[0].raw.d || dataPoints[0].raw.x), formats[unit], locale)}
</div>
<div>
<StatusLight color={labelColors?.[0]?.backgroundColor}>
{formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label}

View file

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

View file

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

View file

@ -4,8 +4,8 @@ import { Banner, Loading, SearchField } from 'react-basics';
import { useMessages } from 'components/hooks';
import Empty from 'components/common/Empty';
import Pager from 'components/common/Pager';
import styles from './DataTable.module.css';
import { FilterQueryResult } from 'lib/types';
import styles from './DataTable.module.css';
const DEFAULT_SEARCH_DELAY = 600;

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { RealtimeData } from 'lib/types';
import { useApi } from 'components/hooks';
import { useApi } from './useApi';
import { REALTIME_INTERVAL, REALTIME_RANGE } from 'lib/constants';
import { startOfMinute, subMinutes } from 'date-fns';
import { percentFilter } from 'lib/filters';

View file

@ -4,8 +4,7 @@ import { useFilterParams } from '../useFilterParams';
export function useWebsiteMetrics(
websiteId: string,
type: string,
limit: number,
queryParams: { type: string; limit: number; search: string; startAt?: number; endAt?: number },
options?: Omit<UseQueryOptions & { onDataLoad?: (data: any) => void }, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
@ -17,19 +16,17 @@ export function useWebsiteMetrics(
{
websiteId,
...params,
type,
limit,
...queryParams,
},
],
queryFn: async () => {
const filters = { ...params };
filters[type] = undefined;
filters[queryParams.type] = undefined;
const data = await get(`/websites/${websiteId}/metrics`, {
...filters,
type,
limit,
...queryParams,
});
options?.onDataLoad?.(data);

View file

@ -4,14 +4,15 @@ import { useFilterParams } from '..//useFilterParams';
export function useWebsitePageviews(
websiteId: string,
compare?: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const params = useFilterParams(websiteId);
return useQuery({
queryKey: ['websites:pageviews', { websiteId, ...params }],
queryFn: () => get(`/websites/${websiteId}/pageviews`, params),
queryKey: ['websites:pageviews', { websiteId, ...params, compare }],
queryFn: () => get(`/websites/${websiteId}/pageviews`, { ...params, compare }),
enabled: !!websiteId,
...options,
});

View file

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

View file

@ -1,4 +1,4 @@
import { useApi } from 'components/hooks';
import { useApi } from './useApi';
export function useWebsiteValues({
websiteId,

View file

@ -10,7 +10,7 @@ export function useCountryNames(locale: string) {
const [list, setList] = useState(countryNames[locale] || enUS);
async function loadData(locale: string) {
const { data } = await httpGet(`${process.env.basePath}/intl/country/${locale}.json`);
const { data } = await httpGet(`${process.env.basePath || ''}/intl/country/${locale}.json`);
if (data) {
countryNames[locale] = data;

View file

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

View file

@ -4,8 +4,8 @@ import { useTimezone } from './useTimezone';
import { zonedTimeToUtc } from 'date-fns-tz';
export function useFilterParams(websiteId: string) {
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, unit, offset } = dateRange;
const { dateRange } = useDateRange(websiteId);
const { startDate, endDate, unit } = dateRange;
const { timezone } = useTimezone();
const {
query: { url, referrer, title, query, os, browser, device, country, region, city, event },
@ -15,7 +15,6 @@ export function useFilterParams(websiteId: string) {
startAt: +zonedTimeToUtc(startDate, timezone),
endAt: +zonedTimeToUtc(endDate, timezone),
unit,
offset,
timezone,
url,
referrer,

View file

@ -10,7 +10,7 @@ export function useLanguageNames(locale) {
const [list, setList] = useState(languageNames[locale] || enUS);
async function loadData(locale) {
const { data } = await httpGet(`${process.env.basePath}/intl/language/${locale}.json`);
const { data } = await httpGet(`${process.env.basePath || ''}/intl/language/${locale}.json`);
if (data) {
languageNames[locale] = data;

View file

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

View file

@ -6,6 +6,7 @@ import Bolt from 'assets/bolt.svg';
import Calendar from 'assets/calendar.svg';
import Change from 'assets/change.svg';
import Clock from 'assets/clock.svg';
import Compare from 'assets/compare.svg';
import Dashboard from 'assets/dashboard.svg';
import Eye from 'assets/eye.svg';
import Gear from 'assets/gear.svg';
@ -32,6 +33,7 @@ const icons = {
Calendar,
Change,
Clock,
Compare,
Dashboard,
Eye,
Gear,

View file

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

View file

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

View file

@ -8,6 +8,10 @@
border-top: 1px solid var(--base300);
}
.row.compare {
grid-template-columns: max-content 1fr 1fr;
}
.col {
padding: 20px;
min-height: 430px;

View file

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

View file

@ -88,6 +88,8 @@ export const labels = defineMessages({
leaveTeam: { id: 'label.leave-team', defaultMessage: 'Leave team' },
refresh: { id: 'label.refresh', defaultMessage: 'Refresh' },
pages: { id: 'label.pages', defaultMessage: 'Pages' },
entry: { id: 'label.entry', defaultMessage: 'Entry URL' },
exit: { id: 'label.exit', defaultMessage: 'Exit URL' },
referrers: { id: 'label.referrers', defaultMessage: 'Referrers' },
hosts: { id: 'label.hosts', defaultMessage: 'Hosts' },
screens: { id: 'label.screens', defaultMessage: 'Screens' },
@ -96,6 +98,9 @@ export const labels = defineMessages({
devices: { id: 'label.devices', defaultMessage: 'Devices' },
countries: { id: 'label.countries', defaultMessage: 'Countries' },
languages: { id: 'label.languages', defaultMessage: 'Languages' },
count: { id: 'label.count', defaultMessage: 'Count' },
average: { id: 'label.average', defaultMessage: 'Average' },
sum: { id: 'label.sum', defaultMessage: 'Sum' },
event: { id: 'label.event', defaultMessage: 'Event' },
events: { id: 'label.events', defaultMessage: 'Events' },
query: { id: 'label.query', defaultMessage: 'Query' },
@ -108,6 +113,7 @@ export const labels = defineMessages({
views: { id: 'label.views', defaultMessage: 'Views' },
none: { id: 'label.none', defaultMessage: 'None' },
clearAll: { id: 'label.clear-all', defaultMessage: 'Clear all' },
property: { id: 'label.property', defaultMessage: 'Property' },
today: { id: 'label.today', defaultMessage: 'Today' },
lastHours: { id: 'label.last-hours', defaultMessage: 'Last {x} hours' },
yesterday: { id: 'label.yesterday', defaultMessage: 'Yesterday' },
@ -131,7 +137,7 @@ export const labels = defineMessages({
uniqueVisitors: { id: 'label.unique-visitors', defaultMessage: 'Unique visitors' },
bounceRate: { id: 'label.bounce-rate', defaultMessage: 'Bounce rate' },
viewsPerVisit: { id: 'label.views-per-visit', defaultMessage: 'Views per visit' },
averageVisitTime: { id: 'label.average-visit-time', defaultMessage: 'Average visit time' },
visitDuration: { id: 'label.visit-duration', defaultMessage: 'Visit duration' },
desktop: { id: 'label.desktop', defaultMessage: 'Desktop' },
laptop: { id: 'label.laptop', defaultMessage: 'Laptop' },
tablet: { id: 'label.tablet', defaultMessage: 'Tablet' },
@ -179,8 +185,6 @@ export const labels = defineMessages({
before: { id: 'label.before', defaultMessage: 'Before' },
after: { id: 'label.after', defaultMessage: 'After' },
total: { id: 'label.total', defaultMessage: 'Total' },
sum: { id: 'label.sum', defaultMessage: 'Sum' },
average: { id: 'label.average', defaultMessage: 'Average' },
min: { id: 'label.min', defaultMessage: 'Min' },
max: { id: 'label.max', defaultMessage: 'Max' },
unique: { id: 'label.unique', defaultMessage: 'Unique' },
@ -222,6 +226,10 @@ export const labels = defineMessages({
id: 'message.viewed-page',
defaultMessage: 'Viewed page',
},
collectedData: {
id: 'message.collected-data',
defaultMessage: 'Collected data',
},
triggeredEvent: {
id: 'message.triggered-event',
defaultMessage: 'Triggered event',
@ -236,7 +244,25 @@ export const labels = defineMessages({
defaultMessage: 'Track your campaigns through UTM parameters.',
},
steps: { id: 'label.steps', defaultMessage: 'Steps' },
startStep: { id: 'label.start-step', defaultMessage: 'Start Step' },
endStep: { id: 'label.end-step', defaultMessage: 'End Step' },
addStep: { id: 'label.add-step', defaultMessage: 'Add step' },
goal: { id: 'label.goal', defaultMessage: 'Goal' },
goals: { id: 'label.goals', defaultMessage: 'Goals' },
goalsDescription: {
id: 'label.goals-description',
defaultMessage: 'Track your goals for pageviews and events.',
},
journey: { id: 'label.journey', defaultMessage: 'Journey' },
journeyDescription: {
id: 'label.journey-description',
defaultMessage: 'Understand how users nagivate through your website.',
},
compare: { id: 'label.compare', defaultMessage: 'Compare' },
current: { id: 'label.current', defaultMessage: 'Current' },
previous: { id: 'label.previous', defaultMessage: 'Previous' },
previousPeriod: { id: 'label.previous-period', defaultMessage: 'Previous period' },
previousYear: { id: 'label.previous-year', defaultMessage: 'Previous year' },
});
export const messages = defineMessages({

View file

@ -11,7 +11,7 @@ export function BrowsersTable(props: MetricsTableProps) {
return (
<FilterLink id="browser" value={browser} label={formatBrowser(browser)}>
<img
src={`${process.env.basePath}/images/browsers/${browser || 'unknown'}.png`}
src={`${process.env.basePath || ''}/images/browsers/${browser || 'unknown'}.png`}
alt={browser}
width={16}
height={16}

View file

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

View file

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

View file

@ -20,7 +20,7 @@ export function CitiesTable(props: MetricsTableProps) {
<FilterLink id="city" value={city} label={renderLabel(city, country)}>
{country && (
<img
src={`${process.env.basePath}/images/flags/${country?.toLowerCase() || 'xx'}.png`}
src={`${process.env.basePath || ''}/images/flags/${country?.toLowerCase() || 'xx'}.png`}
alt={country}
/>
)}

View file

@ -27,7 +27,7 @@ export function CountriesTable({
label={formatCountry(code)}
>
<img
src={`${process.env.basePath}/images/flags/${code?.toLowerCase() || 'xx'}.png`}
src={`${process.env.basePath || ''}/images/flags/${code?.toLowerCase() || 'xx'}.png`}
alt={code}
/>
</FilterLink>

View file

@ -11,7 +11,9 @@ export function DevicesTable(props: MetricsTableProps) {
return (
<FilterLink id="device" value={labels[device] && device} label={formatDevice(device)}>
<img
src={`${process.env.basePath}/images/device/${device?.toLowerCase() || 'unknown'}.png`}
src={`${process.env.basePath || ''}/images/device/${
device?.toLowerCase() || 'unknown'
}.png`}
alt={device}
width={16}
height={16}

View file

@ -13,7 +13,9 @@ export interface EventsChartProps {
}
export function EventsChart({ websiteId, className }: EventsChartProps) {
const [{ startDate, endDate, unit }] = useDateRange(websiteId);
const {
dateRange: { startDate, endDate, unit },
} = useDateRange(websiteId);
const { locale } = useLocale();
const { data, isLoading } = useWebsiteEvents(websiteId);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,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;
@ -27,6 +26,8 @@ export interface MetricsTableProps extends ListTableProps {
onDataLoad?: (data: any) => void;
onSearch?: (search: string) => void;
allowSearch?: boolean;
showMore?: boolean;
params?: { [key: string]: any };
children?: ReactNode;
}
@ -39,6 +40,8 @@ export function MetricsTable({
onDataLoad,
delay = null,
allowSearch = false,
showMore = true,
params,
children,
...props
}: MetricsTableProps) {
@ -48,10 +51,14 @@ export function MetricsTable({
const { formatMessage, labels } = useMessages();
const { dir } = useLocale();
const { data, isLoading, isFetched, error } = useWebsiteMetrics(websiteId, type, limit, {
retryDelay: delay || DEFAULT_ANIMATION_DURATION,
onDataLoad,
});
const { data, isLoading, isFetched, error } = useWebsiteMetrics(
websiteId,
{ type, limit, search, ...params },
{
retryDelay: delay || DEFAULT_ANIMATION_DURATION,
onDataLoad,
},
);
const filteredData = useMemo(() => {
if (data) {
@ -94,7 +101,7 @@ export function MetricsTable({
)}
{!data && isLoading && !isFetched && <Loading icon="dots" />}
<div className={styles.footer}>
{data && !error && limit && (
{showMore && data && !error && limit && (
<LinkButton href={renderUrl({ view: type })} variant="quiet">
<Text>{formatMessage(labels.more)}</Text>
<Icon size="sm" rotate={dir === 'rtl' ? 180 : 0}>

View file

@ -1,24 +1,26 @@
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 = [
@ -26,6 +28,14 @@ export function PagesTable({ allowFilter, domainName, ...props }: PagesTableProp
label: 'URL',
key: 'url',
},
{
label: formatMessage(labels.entry),
key: 'entry',
},
{
label: formatMessage(labels.exit),
key: 'exit',
},
{
label: formatMessage(labels.title),
key: 'title',
@ -35,12 +45,12 @@ 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={
view === 'url'
? `${domainName.startsWith('http') ? domainName : `https://${domainName}`}${x}`
view !== 'title'
? `${domain.startsWith('http') ? domain : `https://${domain}`}${x}`
: null
}
/>
@ -50,7 +60,6 @@ export function PagesTable({ allowFilter, domainName, ...props }: PagesTableProp
return (
<MetricsTable
{...props}
domainName={domainName}
title={formatMessage(labels.pages)}
type={view}
metric={formatMessage(labels.views)}

View file

@ -5,8 +5,12 @@ import { renderDateLabels } from 'lib/charts';
export interface PageviewsChartProps extends BarChartProps {
data: {
sessions: any[];
pageviews: any[];
sessions: any[];
compare?: {
pageviews: any[];
sessions: any[];
};
};
unit: string;
isLoading?: boolean;
@ -29,13 +33,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]);

View file

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

View file

@ -20,7 +20,7 @@ export function RegionsTable(props: MetricsTableProps) {
return (
<FilterLink id="region" className={locale} value={code} label={renderLabel(code, country)}>
<img
src={`${process.env.basePath}/images/flags/${country?.toLowerCase() || 'xx'}.png`}
src={`${process.env.basePath || ''}/images/flags/${country?.toLowerCase() || 'xx'}.png`}
alt={code}
/>
</FilterLink>

View file

@ -54,7 +54,7 @@ export function WorldMap({ data = [], className }: { data?: any[]; className?: s
>
<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];