first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

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

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

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

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

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

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

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

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

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