145
common/resources/client/admin/analytics/admin-header-report.tsx
Executable file
145
common/resources/client/admin/analytics/admin-header-report.tsx
Executable file
@@ -0,0 +1,145 @@
|
||||
import {HeaderDatum} from '@common/admin/analytics/use-admin-report';
|
||||
import React, {
|
||||
cloneElement,
|
||||
Fragment,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
import {TrendingUpIcon} from '@common/icons/material/TrendingUp';
|
||||
import {TrendingDownIcon} from '@common/icons/material/TrendingDown';
|
||||
import {createSvgIconFromTree} from '@common/icons/create-svg-icon';
|
||||
import {AdminReportPageColGap} from '@common/admin/analytics/visitors-report-charts';
|
||||
import {FormattedNumber} from '@common/i18n/formatted-number';
|
||||
import {FormattedBytes} from '@common/uploads/formatted-bytes';
|
||||
import {TrendingFlatIcon} from '@common/icons/material/TrendingFlat';
|
||||
import {AnimatePresence, m} from 'framer-motion';
|
||||
import {opacityAnimation} from '@common/ui/animation/opacity-animation';
|
||||
import {Skeleton} from '@common/ui/skeleton/skeleton';
|
||||
|
||||
interface AdminHeaderReportProps {
|
||||
report?: HeaderDatum[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
export function AdminHeaderReport({report, isLoading}: AdminHeaderReportProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex h-[97px] flex-shrink-0 items-center overflow-x-auto ${AdminReportPageColGap}`}
|
||||
>
|
||||
{report?.map(datum => (
|
||||
<ReportItem key={datum.name} datum={datum} isLoading={isLoading} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ValueMetricItemProps {
|
||||
datum: HeaderDatum;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
function ReportItem({datum, isLoading = false}: ValueMetricItemProps) {
|
||||
let icon;
|
||||
if (isValidElement(datum.icon)) {
|
||||
icon = cloneElement(datum.icon, {size: 'lg'});
|
||||
} else {
|
||||
const IconEl = createSvgIconFromTree(datum.icon);
|
||||
icon = <IconEl size="lg" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={datum.name}
|
||||
className="rounded-panel flex h-full flex-auto items-center gap-18 whitespace-nowrap border p-20"
|
||||
>
|
||||
<div className="flex-shrink-0 rounded-lg bg-primary-light/20 p-10 text-primary">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-auto">
|
||||
<div className="flex items-center justify-between gap-20">
|
||||
<div className="text-lg font-bold text-main">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{isLoading ? (
|
||||
<m.div key="skeleton" {...opacityAnimation}>
|
||||
<Skeleton className="min-w-24" />
|
||||
</m.div>
|
||||
) : (
|
||||
<m.div key="value" {...opacityAnimation}>
|
||||
<FormattedValue datum={datum} />
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-20">
|
||||
<h2 className="text-sm text-muted">{datum.name}</h2>
|
||||
{(datum.percentageChange != null || datum.previousValue != null) && (
|
||||
<div className="flex items-center gap-10">
|
||||
<TrendingIndicator datum={datum} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormattedValueProps {
|
||||
datum: HeaderDatum;
|
||||
}
|
||||
function FormattedValue({datum}: FormattedValueProps) {
|
||||
switch (datum.type) {
|
||||
case 'fileSize':
|
||||
return <FormattedBytes bytes={datum.currentValue} />;
|
||||
case 'percentage':
|
||||
return (
|
||||
<FormattedNumber
|
||||
value={datum.currentValue}
|
||||
style="percent"
|
||||
maximumFractionDigits={1}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <FormattedNumber value={datum.currentValue} />;
|
||||
}
|
||||
}
|
||||
|
||||
interface TrendingIndicatorProps {
|
||||
datum: HeaderDatum;
|
||||
}
|
||||
function TrendingIndicator({datum}: TrendingIndicatorProps) {
|
||||
const percentage = calculatePercentage(datum);
|
||||
let icon: ReactElement;
|
||||
if (percentage > 0) {
|
||||
icon = <TrendingUpIcon size="md" className="text-positive" />;
|
||||
} else if (percentage === 0) {
|
||||
icon = <TrendingFlatIcon className="text-muted" />;
|
||||
} else {
|
||||
icon = <TrendingDownIcon className="text-danger" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{icon}
|
||||
<div className="text-sm font-semibold text-muted">{percentage}%</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function calculatePercentage({
|
||||
percentageChange,
|
||||
previousValue,
|
||||
currentValue,
|
||||
}: HeaderDatum) {
|
||||
if (
|
||||
percentageChange != null ||
|
||||
previousValue == null ||
|
||||
currentValue == null
|
||||
) {
|
||||
return percentageChange ?? 0;
|
||||
}
|
||||
|
||||
if (previousValue === 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return Math.round(((currentValue - previousValue) / previousValue) * 100);
|
||||
}
|
||||
33
common/resources/client/admin/analytics/admin-report-page.tsx
Executable file
33
common/resources/client/admin/analytics/admin-report-page.tsx
Executable file
@@ -0,0 +1,33 @@
|
||||
import React, {useState} from 'react';
|
||||
import {useAdminReport} from './use-admin-report';
|
||||
import {Trans} from '../../i18n/trans';
|
||||
import {StaticPageTitle} from '../../seo/static-page-title';
|
||||
import {AdminHeaderReport} from '@common/admin/analytics/admin-header-report';
|
||||
import {VisitorsReportCharts} from '@common/admin/analytics/visitors-report-charts';
|
||||
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
|
||||
import {DateRangePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-presets';
|
||||
import {ReportDateSelector} from '@common/admin/analytics/report-date-selector';
|
||||
|
||||
export default function AdminReportPage() {
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>(() => {
|
||||
// This week
|
||||
return DateRangePresets[2].getRangeValue();
|
||||
});
|
||||
const {isLoading, data} = useAdminReport({dateRange});
|
||||
const title = <Trans message="Visitors report" />;
|
||||
|
||||
return (
|
||||
<div className="min-h-full gap-12 overflow-x-hidden p-12 md:gap-18 md:p-18">
|
||||
<div className="mb-24 items-center justify-between gap-24 md:flex">
|
||||
<StaticPageTitle>{title}</StaticPageTitle>
|
||||
<h1 className="mb-24 text-3xl font-light md:mb-0">{title}</h1>
|
||||
<ReportDateSelector value={dateRange} onChange={setDateRange} />
|
||||
</div>
|
||||
<AdminHeaderReport report={data?.headerReport} />
|
||||
<VisitorsReportCharts
|
||||
report={data?.visitorsReport}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
common/resources/client/admin/analytics/geo-chart/geo-chart.tsx
Executable file
110
common/resources/client/admin/analytics/geo-chart/geo-chart.tsx
Executable file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
LocationDatasetItem,
|
||||
ReportMetric,
|
||||
} from '@common/admin/analytics/report-metric';
|
||||
import React, {useMemo, useRef} from 'react';
|
||||
import {useGoogleGeoChart} from './use-google-geo-chart';
|
||||
import {ChartLayout, ChartLayoutProps} from '@common/charts/chart-layout';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {ChartLoadingIndicator} from '@common/charts/chart-loading-indicator';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {ArrowBackIcon} from '@common/icons/material/ArrowBack';
|
||||
import clsx from 'clsx';
|
||||
import {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';
|
||||
import {FormattedCountryName} from '@common/i18n/formatted-country-name';
|
||||
|
||||
interface GeoChartData extends Partial<ChartLayoutProps> {
|
||||
data?: ReportMetric<LocationDatasetItem>;
|
||||
onCountrySelected?: (countryCode: string | undefined) => void;
|
||||
country?: string;
|
||||
}
|
||||
export function GeoChart({
|
||||
data: metricData,
|
||||
isLoading,
|
||||
onCountrySelected,
|
||||
country,
|
||||
...layoutProps
|
||||
}: GeoChartData) {
|
||||
const placeholderRef = useRef<HTMLDivElement>(null);
|
||||
const regionInteractivity = !!onCountrySelected;
|
||||
|
||||
// memo data to avoid redrawing chart on rerender
|
||||
const initialData = metricData?.datasets[0].data;
|
||||
const data = useMemo(() => {
|
||||
return initialData || [];
|
||||
}, [initialData]);
|
||||
useGoogleGeoChart({placeholderRef, data, country, onCountrySelected});
|
||||
|
||||
return (
|
||||
<ChartLayout
|
||||
{...layoutProps}
|
||||
className="min-w-500"
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<Trans message="Top Locations" />
|
||||
{country ? (
|
||||
<span className="pl-4">
|
||||
({<FormattedCountryName code={country} />})
|
||||
</span>
|
||||
) : null}
|
||||
{regionInteractivity && <InfoTrigger />}
|
||||
</div>
|
||||
}
|
||||
contentIsFlex={isLoading}
|
||||
>
|
||||
{isLoading && <ChartLoadingIndicator />}
|
||||
<div className="flex gap-24">
|
||||
<div
|
||||
ref={placeholderRef}
|
||||
className="flex-auto w-[480px] min-h-[340px]"
|
||||
/>
|
||||
<div className="w-[170px]">
|
||||
<div className="text-sm max-h-[340px] w-full flex-initial overflow-y-auto">
|
||||
{data.map(location => (
|
||||
<div
|
||||
key={location.label}
|
||||
className={clsx(
|
||||
'flex items-center gap-4 mb-4',
|
||||
regionInteractivity && 'cursor-pointer hover:underline'
|
||||
)}
|
||||
role={regionInteractivity ? 'button' : undefined}
|
||||
onClick={() => {
|
||||
onCountrySelected?.(location.code);
|
||||
}}
|
||||
>
|
||||
<div className="max-w-110 whitespace-nowrap overflow-hidden overflow-ellipsis">
|
||||
{location.label}
|
||||
</div>
|
||||
<div>({location.percentage})%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{country && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="mt-14"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => {
|
||||
onCountrySelected?.(undefined);
|
||||
}}
|
||||
>
|
||||
<Trans message="Back to countries" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ChartLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoTrigger() {
|
||||
return (
|
||||
<InfoDialogTrigger
|
||||
title={<Trans message="Zooming in" />}
|
||||
body={
|
||||
<Trans message="Click on a country inside the map or country list to zoom in and see city data for that country." />
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
116
common/resources/client/admin/analytics/geo-chart/use-google-geo-chart.ts
Executable file
116
common/resources/client/admin/analytics/geo-chart/use-google-geo-chart.ts
Executable file
@@ -0,0 +1,116 @@
|
||||
import lazyLoader from '../../../utils/http/lazy-loader';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {RefObject, useCallback, useEffect, useRef} from 'react';
|
||||
import {useThemeSelector} from '@common/ui/themes/theme-selector-context';
|
||||
import {themeValueToHex} from '@common/ui/themes/utils/theme-value-to-hex';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {LocationDatasetItem} from '@common/admin/analytics/report-metric';
|
||||
|
||||
const loaderUrl = 'https://www.gstatic.com/charts/loader.js';
|
||||
|
||||
interface UseGoogleGeoChartProps {
|
||||
placeholderRef: RefObject<HTMLDivElement>;
|
||||
data: LocationDatasetItem[];
|
||||
onCountrySelected?: (countryCode: string) => void;
|
||||
country?: string;
|
||||
}
|
||||
export function useGoogleGeoChart({
|
||||
placeholderRef,
|
||||
data,
|
||||
country,
|
||||
onCountrySelected,
|
||||
}: UseGoogleGeoChartProps) {
|
||||
const {trans} = useTrans();
|
||||
const {analytics} = useSettings();
|
||||
const apiKey = analytics?.gchart_api_key;
|
||||
const {selectedTheme} = useThemeSelector();
|
||||
const geoChartRef = useRef<google.visualization.GeoChart>();
|
||||
// only allow selecting countries, not cities
|
||||
const regionInteractivity = !!onCountrySelected && !country;
|
||||
const drawGoogleChart = useCallback(() => {
|
||||
if (typeof google === 'undefined') return;
|
||||
|
||||
const seedData = data.map(location => [location.label, location.value]);
|
||||
seedData.unshift([
|
||||
country ? trans(message('City')) : trans(message('Country')),
|
||||
trans(message('Clicks')),
|
||||
]);
|
||||
|
||||
const backgroundColor = `${themeValueToHex(
|
||||
selectedTheme.values['--be-paper'],
|
||||
)}`;
|
||||
const chartColor = `${themeValueToHex(
|
||||
selectedTheme.values['--be-primary'],
|
||||
)}`;
|
||||
|
||||
const options: google.visualization.GeoChartOptions = {
|
||||
colorAxis: {colors: [chartColor]},
|
||||
backgroundColor,
|
||||
region: country ? country.toUpperCase() : undefined,
|
||||
resolution: country ? 'provinces' : 'countries',
|
||||
displayMode: country ? 'markers' : 'regions',
|
||||
enableRegionInteractivity: regionInteractivity,
|
||||
};
|
||||
|
||||
if (
|
||||
!geoChartRef.current &&
|
||||
placeholderRef.current &&
|
||||
google?.visualization?.GeoChart
|
||||
) {
|
||||
geoChartRef.current = new google.visualization.GeoChart(
|
||||
placeholderRef.current,
|
||||
);
|
||||
}
|
||||
geoChartRef.current?.draw(
|
||||
google.visualization.arrayToDataTable(seedData),
|
||||
options,
|
||||
);
|
||||
}, [
|
||||
selectedTheme,
|
||||
data,
|
||||
placeholderRef,
|
||||
trans,
|
||||
country,
|
||||
regionInteractivity,
|
||||
]);
|
||||
|
||||
const initGoogleGeoChart = useCallback(async () => {
|
||||
if (lazyLoader.isLoadingOrLoaded(loaderUrl)) return;
|
||||
await lazyLoader.loadAsset(loaderUrl, {type: 'js', id: 'google-charts-js'});
|
||||
await google.charts.load('current', {
|
||||
packages: ['geochart'],
|
||||
mapsApiKey: apiKey,
|
||||
});
|
||||
drawGoogleChart();
|
||||
}, [apiKey, drawGoogleChart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (geoChartRef.current && onCountrySelected) {
|
||||
google.visualization.events.addListener(
|
||||
geoChartRef.current,
|
||||
'regionClick',
|
||||
(a: {region: string}) => onCountrySelected?.(a.region),
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (geoChartRef.current) {
|
||||
google.visualization.events.removeAllListeners(geoChartRef.current);
|
||||
}
|
||||
};
|
||||
// this will correctly run when geochart instance is set on ref
|
||||
}, [onCountrySelected, geoChartRef.current]);
|
||||
|
||||
// on component load: load chart library then draw, otherwise just draw
|
||||
useEffect(() => {
|
||||
initGoogleGeoChart();
|
||||
}, [initGoogleGeoChart]);
|
||||
|
||||
// redraw chart if data or theme changes
|
||||
useEffect(() => {
|
||||
drawGoogleChart();
|
||||
}, [selectedTheme, drawGoogleChart, data]);
|
||||
|
||||
return {drawGoogleChart};
|
||||
}
|
||||
109
common/resources/client/admin/analytics/report-date-selector.tsx
Executable file
109
common/resources/client/admin/analytics/report-date-selector.tsx
Executable file
@@ -0,0 +1,109 @@
|
||||
import {useDateRangePickerState} from '@common/ui/forms/input-field/date/date-range-picker/use-date-range-picker-state';
|
||||
import {DialogTrigger} from '@common/ui/overlays/dialog/dialog-trigger';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {DateRangeIcon} from '@common/icons/material/DateRange';
|
||||
import {FormattedDateTimeRange} from '@common/i18n/formatted-date-time-range';
|
||||
import {DateRangeDialog} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-dialog';
|
||||
import React from 'react';
|
||||
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
|
||||
import {useIsMobileMediaQuery} from '@common/utils/hooks/is-mobile-media-query';
|
||||
import {DateFormatPresets} from '@common/i18n/formatted-date';
|
||||
import {DateRangeComparePresets} from '@common/ui/forms/input-field/date/date-range-picker/dialog/date-range-compare-presets';
|
||||
import {Granularity} from '@common/ui/forms/input-field/date/date-picker/use-date-picker-state';
|
||||
|
||||
const monthDayFormat: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
};
|
||||
|
||||
interface ReportDataSelectorProps {
|
||||
value: DateRangeValue;
|
||||
disabled?: boolean;
|
||||
onChange: (value: DateRangeValue) => void;
|
||||
compactOnMobile?: boolean;
|
||||
enableCompare?: boolean;
|
||||
granularity?: Granularity;
|
||||
}
|
||||
export function ReportDateSelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
compactOnMobile = true,
|
||||
enableCompare = false,
|
||||
granularity = 'minute',
|
||||
}: ReportDataSelectorProps) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
return (
|
||||
<DialogTrigger
|
||||
type="popover"
|
||||
onClose={value => {
|
||||
if (value) {
|
||||
onChange(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="chip"
|
||||
endIcon={<DateRangeIcon />}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FormattedDateTimeRange
|
||||
start={value.start}
|
||||
end={value.end}
|
||||
options={
|
||||
isMobile && compactOnMobile
|
||||
? monthDayFormat
|
||||
: DateFormatPresets.short
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
<DateSelectorDialog
|
||||
value={value}
|
||||
enableCompare={enableCompare}
|
||||
granularity={granularity}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface DateSelectorDialogProps {
|
||||
value: DateRangeValue;
|
||||
enableCompare: boolean;
|
||||
granularity: Granularity;
|
||||
}
|
||||
function DateSelectorDialog({
|
||||
value,
|
||||
enableCompare,
|
||||
granularity,
|
||||
}: DateSelectorDialogProps) {
|
||||
const isMobile = useIsMobileMediaQuery();
|
||||
const state = useDateRangePickerState({
|
||||
granularity,
|
||||
defaultValue: {
|
||||
start: value.start,
|
||||
end: value.end,
|
||||
preset: value.preset,
|
||||
},
|
||||
closeDialogOnSelection: false,
|
||||
});
|
||||
const compareHasInitialValue = !!value.compareStart && !!value.compareEnd;
|
||||
const compareState = useDateRangePickerState({
|
||||
granularity,
|
||||
defaultValue: compareHasInitialValue
|
||||
? {
|
||||
start: value.compareStart,
|
||||
end: value.compareEnd,
|
||||
preset: value.comparePreset,
|
||||
}
|
||||
: DateRangeComparePresets[0].getRangeValue(state.selectedValue),
|
||||
});
|
||||
return (
|
||||
<DateRangeDialog
|
||||
state={state}
|
||||
compareState={enableCompare ? compareState : undefined}
|
||||
compareVisibleDefault={compareHasInitialValue}
|
||||
showInlineDatePickerField={!isMobile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
26
common/resources/client/admin/analytics/report-metric.ts
Executable file
26
common/resources/client/admin/analytics/report-metric.ts
Executable file
@@ -0,0 +1,26 @@
|
||||
export type RangedDatasetGranularity =
|
||||
| 'minute'
|
||||
| 'hour'
|
||||
| 'day'
|
||||
| 'week'
|
||||
| 'month'
|
||||
| 'year';
|
||||
|
||||
export interface ReportMetric<T = unknown, E = unknown> {
|
||||
labels?: string[];
|
||||
granularity?: RangedDatasetGranularity;
|
||||
total?: number;
|
||||
datasets: ({label: string; data: T[]} & E)[];
|
||||
}
|
||||
|
||||
export interface DatasetItem {
|
||||
label?: string;
|
||||
value: number;
|
||||
date?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface LocationDatasetItem extends DatasetItem {
|
||||
percentage: number;
|
||||
code: string;
|
||||
}
|
||||
52
common/resources/client/admin/analytics/use-admin-report.ts
Executable file
52
common/resources/client/admin/analytics/use-admin-report.ts
Executable file
@@ -0,0 +1,52 @@
|
||||
import {keepPreviousData, useQuery} from '@tanstack/react-query';
|
||||
import {BackendResponse} from '../../http/backend-response/backend-response';
|
||||
import {apiClient} from '../../http/query-client';
|
||||
import {VisitorsReportData} from './visitors-report-data';
|
||||
import {IconTree} from '../../icons/create-svg-icon';
|
||||
import {DateRangeValue} from '@common/ui/forms/input-field/date/date-range-picker/date-range-value';
|
||||
import {ReactElement} from 'react';
|
||||
import {SvgIconProps} from '@common/icons/svg-icon';
|
||||
|
||||
const Endpoint = 'admin/reports';
|
||||
|
||||
export interface HeaderDatum {
|
||||
icon: IconTree[] | ReactElement<SvgIconProps>;
|
||||
name: string;
|
||||
type?: 'number' | 'fileSize' | 'percentage';
|
||||
currentValue: number;
|
||||
previousValue?: number;
|
||||
percentageChange?: number;
|
||||
}
|
||||
|
||||
interface FetchAnalyticsReportResponse extends BackendResponse {
|
||||
visitorsReport: VisitorsReportData;
|
||||
headerReport: HeaderDatum[];
|
||||
}
|
||||
|
||||
interface Payload {
|
||||
types?: ('visitors' | 'header')[];
|
||||
dateRange?: DateRangeValue;
|
||||
}
|
||||
export function useAdminReport(payload: Payload = {}) {
|
||||
return useQuery({
|
||||
queryKey: [Endpoint, payload],
|
||||
queryFn: () => fetchAnalyticsReport(payload),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
function fetchAnalyticsReport({
|
||||
types,
|
||||
dateRange,
|
||||
}: Payload): Promise<FetchAnalyticsReportResponse> {
|
||||
const params: Record<string, any> = {};
|
||||
if (types) {
|
||||
params.types = types.join(',');
|
||||
}
|
||||
if (dateRange) {
|
||||
params.startDate = dateRange.start.toAbsoluteString();
|
||||
params.endDate = dateRange.end.toAbsoluteString();
|
||||
params.timezone = dateRange.start.timeZone;
|
||||
}
|
||||
return apiClient.get(Endpoint, {params}).then(response => response.data);
|
||||
}
|
||||
64
common/resources/client/admin/analytics/visitors-report-charts.tsx
Executable file
64
common/resources/client/admin/analytics/visitors-report-charts.tsx
Executable file
@@ -0,0 +1,64 @@
|
||||
import React, {Fragment} from 'react';
|
||||
import {LineChart} from '@common/charts/line-chart';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {PolarAreaChart} from '@common/charts/polar-area-chart';
|
||||
import {BarChart} from '@common/charts/bar-chart';
|
||||
import {VisitorsReportData} from '@common/admin/analytics/visitors-report-data';
|
||||
import {FormattedNumber} from '@common/i18n/formatted-number';
|
||||
import {GeoChart} from '@common/admin/analytics/geo-chart/geo-chart';
|
||||
|
||||
export const AdminReportPageColGap = 'gap-12 md:gap-16 mb-12 md:mb-16';
|
||||
const rowClassName = `flex flex-col md:flex-row md:items-center overflow-x-auto ${AdminReportPageColGap}`;
|
||||
|
||||
interface AdminReportChartsProps {
|
||||
report?: VisitorsReportData;
|
||||
isLoading: boolean;
|
||||
}
|
||||
export function VisitorsReportCharts({
|
||||
report,
|
||||
isLoading,
|
||||
}: AdminReportChartsProps) {
|
||||
const totalViews = report?.pageViews.total;
|
||||
return (
|
||||
<Fragment>
|
||||
<div className={rowClassName}>
|
||||
<LineChart
|
||||
isLoading={isLoading}
|
||||
className="flex-auto"
|
||||
data={report?.pageViews}
|
||||
title={<Trans message="Pageviews" />}
|
||||
description={
|
||||
totalViews ? (
|
||||
<Trans
|
||||
message=":count total views"
|
||||
values={{count: <FormattedNumber value={totalViews} />}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<PolarAreaChart
|
||||
isLoading={isLoading}
|
||||
data={report?.devices}
|
||||
title={<Trans message="Top devices" />}
|
||||
/>
|
||||
</div>
|
||||
<div className={rowClassName}>
|
||||
<BarChart
|
||||
isLoading={isLoading}
|
||||
data={report?.browsers}
|
||||
className="flex-auto md:w-1/3"
|
||||
direction="horizontal"
|
||||
individualBarColors
|
||||
hideLegend
|
||||
title={<Trans message="Top browsers" />}
|
||||
/>
|
||||
<GeoChart
|
||||
isLoading={isLoading}
|
||||
className="flex-auto"
|
||||
data={report?.locations}
|
||||
title={<Trans message="Top locations" />}
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
9
common/resources/client/admin/analytics/visitors-report-data.ts
Executable file
9
common/resources/client/admin/analytics/visitors-report-data.ts
Executable file
@@ -0,0 +1,9 @@
|
||||
import {DatasetItem, LocationDatasetItem, ReportMetric} from './report-metric';
|
||||
|
||||
export interface VisitorsReportData {
|
||||
browsers: ReportMetric<DatasetItem>;
|
||||
platforms: ReportMetric<DatasetItem>;
|
||||
devices: ReportMetric<DatasetItem>;
|
||||
locations: ReportMetric<LocationDatasetItem>;
|
||||
pageViews: ReportMetric<DatasetItem>;
|
||||
}
|
||||
Reference in New Issue
Block a user