Board component, security, test updates
This commit is contained in:
1 parent
f525898b79
commit
ab5b1bf354
23 files changed
+1128
-180
No files matched your search
@@ -8,6 +8,7 @@ import {
|
||||
Loading,
|
||||
Row,
|
||||
Select,
|
||||
Text,
|
||||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
import { useBoardQuery, useMessages, useNavigation, useUpdateQuery } from '@/components/hooks';
|
||||
@@ -97,6 +98,14 @@ export function BoardEditForm({
|
||||
: type === BOARD_TYPES.link
|
||||
? t(labels.link)
|
||||
: t(labels.website);
|
||||
const boardTypeLabel =
|
||||
type === BOARD_TYPES.mixed
|
||||
? t(labels.open)
|
||||
: type === BOARD_TYPES.pixel
|
||||
? t(labels.pixel)
|
||||
: type === BOARD_TYPES.link
|
||||
? t(labels.link)
|
||||
: t(labels.website);
|
||||
|
||||
const handleTypeChange = (value: string) => {
|
||||
setValue('type', value as BoardType, { shouldDirty: true });
|
||||
@@ -126,12 +135,16 @@ export function BoardEditForm({
|
||||
rules={{ required: t(labels.required) }}
|
||||
>
|
||||
<Box width="100%" maxWidth="360px">
|
||||
<Select value={type} onChange={handleTypeChange}>
|
||||
<ListItem id={BOARD_TYPES.mixed}>{t(labels.open)}</ListItem>
|
||||
<ListItem id={BOARD_TYPES.website}>{t(labels.website)}</ListItem>
|
||||
<ListItem id={BOARD_TYPES.pixel}>{t(labels.pixel)}</ListItem>
|
||||
<ListItem id={BOARD_TYPES.link}>{t(labels.link)}</ListItem>
|
||||
</Select>
|
||||
{boardId ? (
|
||||
<Text>{boardTypeLabel}</Text>
|
||||
) : (
|
||||
<Select value={type} onChange={handleTypeChange}>
|
||||
<ListItem id={BOARD_TYPES.mixed}>{t(labels.open)}</ListItem>
|
||||
<ListItem id={BOARD_TYPES.website}>{t(labels.website)}</ListItem>
|
||||
<ListItem id={BOARD_TYPES.pixel}>{t(labels.pixel)}</ListItem>
|
||||
<ListItem id={BOARD_TYPES.link}>{t(labels.link)}</ListItem>
|
||||
</Select>
|
||||
)}
|
||||
</Box>
|
||||
</FormField>
|
||||
{requiresBoardEntity(type) && (
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { useApi, useMessages } from '@/components/hooks';
|
||||
import { LinkSelect } from '@/components/input/LinkSelect';
|
||||
import { PixelSelect } from '@/components/input/PixelSelect';
|
||||
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
|
||||
@@ -29,6 +29,16 @@ import {
|
||||
} from '../boardComponentRegistry';
|
||||
import { BoardComponentRenderer } from './BoardComponentRenderer';
|
||||
|
||||
const COMPONENT_GROUP_ORDER: string[] = [
|
||||
'Traffic',
|
||||
'Events',
|
||||
'Behavior',
|
||||
'Realtime',
|
||||
'Growth',
|
||||
'Revenue',
|
||||
'Content',
|
||||
];
|
||||
|
||||
export function BoardComponentSelect({
|
||||
teamId,
|
||||
boardType,
|
||||
@@ -47,6 +57,7 @@ export function BoardComponentSelect({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, labels, messages } = useMessages();
|
||||
const { get, useQuery } = useApi();
|
||||
const initialEntity = getComponentEntity(initialConfig);
|
||||
const [selectedDef, setSelectedDef] = useState<ComponentDefinition | null>(null);
|
||||
const [configValues, setConfigValues] = useState<Record<string, any>>({});
|
||||
@@ -57,16 +68,17 @@ export function BoardComponentSelect({
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const allDefinitions = useMemo(
|
||||
() => getComponentDefinitions().toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
[],
|
||||
);
|
||||
const allDefinitions = useMemo(() => getComponentDefinitions(), []);
|
||||
const activeEntityType = isOpenBoardType(boardType) ? selectedEntityType : boardEntityType;
|
||||
const isSelectedDefSupported = selectedDef
|
||||
? isBoardComponentSupported(selectedDef.type, activeEntityType)
|
||||
: false;
|
||||
|
||||
const getDefaultConfigValues = (def: ComponentDefinition, config?: BoardComponentConfig) => {
|
||||
const getDefaultConfigValues = (
|
||||
def: ComponentDefinition,
|
||||
config?: BoardComponentConfig,
|
||||
entityType?: BoardEntityType,
|
||||
) => {
|
||||
const defaults: Record<string, any> = {};
|
||||
|
||||
for (const field of def.configFields ?? []) {
|
||||
@@ -77,6 +89,19 @@ export function BoardComponentSelect({
|
||||
Object.assign(defaults, def.defaultProps);
|
||||
}
|
||||
|
||||
for (const field of def.configFields ?? []) {
|
||||
if (field.type !== 'select') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = (entityType && field.optionsByEntityType?.[entityType]) ?? field.options;
|
||||
const currentValue = defaults[field.name];
|
||||
|
||||
if (options?.length && !options.some(option => String(option.value) === String(currentValue))) {
|
||||
defaults[field.name] = options[0].value;
|
||||
}
|
||||
}
|
||||
|
||||
if (config?.props) {
|
||||
Object.assign(defaults, config.props);
|
||||
}
|
||||
@@ -96,10 +121,9 @@ export function BoardComponentSelect({
|
||||
}
|
||||
|
||||
setSelectedDef(definition);
|
||||
setConfigValues(getDefaultConfigValues(definition, initialConfig));
|
||||
setSelectedEntityType(
|
||||
initialEntity.entityType || boardEntityType || BOARD_ENTITY_TYPES.website,
|
||||
);
|
||||
const entityType = initialEntity.entityType || boardEntityType || BOARD_ENTITY_TYPES.website;
|
||||
setConfigValues(getDefaultConfigValues(definition, initialConfig, entityType));
|
||||
setSelectedEntityType(entityType);
|
||||
setSelectedEntityId(initialEntity.entityId || boardEntityId);
|
||||
setTitle(initialConfig.title ?? definition.name);
|
||||
setDescription(initialConfig.description || '');
|
||||
@@ -114,7 +138,7 @@ export function BoardComponentSelect({
|
||||
|
||||
const handleSelectComponent = (def: ComponentDefinition) => {
|
||||
setSelectedDef(def);
|
||||
setConfigValues(getDefaultConfigValues(def));
|
||||
setConfigValues(getDefaultConfigValues(def, undefined, activeEntityType));
|
||||
setTitle(def.name);
|
||||
setDescription('');
|
||||
};
|
||||
@@ -136,10 +160,55 @@ export function BoardComponentSelect({
|
||||
? selectedEntityId
|
||||
: boardEntityId
|
||||
: undefined;
|
||||
const reportFields = selectedDef?.configFields?.filter(
|
||||
field => field.type === 'report' && field.reportType,
|
||||
);
|
||||
const { data: reportOptionsData, isLoading: isLoadingReportOptions } = useQuery({
|
||||
queryKey: [
|
||||
'board-component-report-options',
|
||||
{
|
||||
websiteId: resolvedEntityId,
|
||||
reportTypes: reportFields?.map(field => field.reportType).join(','),
|
||||
},
|
||||
],
|
||||
queryFn: async () => {
|
||||
const types = [...new Set(reportFields?.map(field => field.reportType).filter(Boolean))];
|
||||
|
||||
const entries = await Promise.all(
|
||||
types.map(async type => {
|
||||
const response = await get('/reports', {
|
||||
websiteId: resolvedEntityId,
|
||||
type,
|
||||
pageSize: 1000,
|
||||
});
|
||||
|
||||
return [type, response.data] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
return Object.fromEntries(entries);
|
||||
},
|
||||
enabled: !!resolvedEntityId && !!reportFields?.length,
|
||||
});
|
||||
const reportOptionsByType = reportOptionsData ?? {};
|
||||
const hasRequiredConfig = selectedDef
|
||||
? (selectedDef.configFields ?? []).every(field => {
|
||||
if (!field.required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const value = configValues[field.name];
|
||||
return value !== undefined && value !== null && value !== '';
|
||||
})
|
||||
: false;
|
||||
|
||||
const handleEntityTypeChange = (value: string) => {
|
||||
setSelectedEntityType(value as BoardEntityType);
|
||||
setSelectedEntityId(undefined);
|
||||
setSelectedDef(null);
|
||||
setConfigValues({});
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
@@ -186,7 +255,11 @@ export function BoardComponentSelect({
|
||||
[selectedDef, configValues],
|
||||
);
|
||||
|
||||
const canSave = !!selectedDef && isSelectedDefSupported && (!needsWebsite || !!resolvedEntityId);
|
||||
const canSave =
|
||||
!!selectedDef &&
|
||||
isSelectedDefSupported &&
|
||||
hasRequiredConfig &&
|
||||
(!needsWebsite || !!resolvedEntityId);
|
||||
const availableDefinitions = useMemo(
|
||||
() =>
|
||||
allDefinitions.filter(
|
||||
@@ -195,6 +268,33 @@ export function BoardComponentSelect({
|
||||
),
|
||||
[activeEntityType, allDefinitions, selectedDef?.type],
|
||||
);
|
||||
const groupedDefinitions = useMemo(() => {
|
||||
const order = new Map(COMPONENT_GROUP_ORDER.map((group, index) => [group, index]));
|
||||
const groups = new Map<string, ComponentDefinition[]>();
|
||||
|
||||
for (const def of availableDefinitions) {
|
||||
const group = def.group ?? 'Other';
|
||||
const items = groups.get(group) ?? [];
|
||||
items.push(def);
|
||||
groups.set(group, items);
|
||||
}
|
||||
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => {
|
||||
const left = order.get(a) ?? Number.MAX_SAFE_INTEGER;
|
||||
const right = order.get(b) ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
if (left !== right) {
|
||||
return left - right;
|
||||
}
|
||||
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map(([group, definitions]) => ({
|
||||
group,
|
||||
definitions,
|
||||
}));
|
||||
}, [availableDefinitions]);
|
||||
|
||||
return (
|
||||
<Column gap="4">
|
||||
@@ -310,6 +410,35 @@ export function BoardComponentSelect({
|
||||
style={{ minHeight: 200 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'report' && (
|
||||
<>
|
||||
<Select
|
||||
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
|
||||
onChange={(value: string) => handleConfigChange(field.name, value)}
|
||||
maxHeight={300}
|
||||
popoverProps={{ style: { width: 220 } }}
|
||||
>
|
||||
{(reportOptionsByType[field.reportType ?? ''] ?? []).map(option => (
|
||||
<ListItem key={option.id} id={option.id}>
|
||||
{option.name}
|
||||
</ListItem>
|
||||
))}
|
||||
</Select>
|
||||
{isLoadingReportOptions && (
|
||||
<Text size="xs" color="muted">
|
||||
Loading options...
|
||||
</Text>
|
||||
)}
|
||||
{!isLoadingReportOptions &&
|
||||
resolvedEntityId &&
|
||||
(reportOptionsByType[field.reportType ?? '']?.length ?? 0) === 0 && (
|
||||
<Text size="xs" color="muted">
|
||||
No saved items found.
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Column>
|
||||
))}
|
||||
</Column>
|
||||
@@ -321,40 +450,47 @@ export function BoardComponentSelect({
|
||||
<Column border="left" paddingLeft="4" height="100%" style={{ minHeight: 0 }}>
|
||||
{hasSelectedEntity ? (
|
||||
<Column gap="1" height="100%" style={{ overflowY: 'auto', minHeight: 0 }}>
|
||||
{availableDefinitions.map(def => {
|
||||
const Icon = def.icon;
|
||||
{groupedDefinitions.map(({ group, definitions }) => (
|
||||
<Column key={group} gap="1" paddingBottom="2">
|
||||
<Text size="sm" color="muted" weight="bold">
|
||||
{group}
|
||||
</Text>
|
||||
{definitions.map(def => {
|
||||
const Icon = def.icon;
|
||||
|
||||
return (
|
||||
<Focusable key={def.type}>
|
||||
<Row
|
||||
gap="3"
|
||||
alignItems="flex-start"
|
||||
paddingX="3"
|
||||
paddingY="2"
|
||||
borderRadius
|
||||
backgroundColor={
|
||||
selectedDef?.type === def.type ? 'surface-sunken' : undefined
|
||||
}
|
||||
hover={{ backgroundColor: 'surface-sunken' }}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleSelectComponent(def)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<Column gap="1">
|
||||
<Text
|
||||
size="sm"
|
||||
weight={selectedDef?.type === def.type ? 'bold' : undefined}
|
||||
return (
|
||||
<Focusable key={def.type}>
|
||||
<Row
|
||||
gap="3"
|
||||
alignItems="flex-start"
|
||||
paddingX="3"
|
||||
paddingY="2"
|
||||
borderRadius
|
||||
backgroundColor={
|
||||
selectedDef?.type === def.type ? 'surface-sunken' : undefined
|
||||
}
|
||||
hover={{ backgroundColor: 'surface-sunken' }}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleSelectComponent(def)}
|
||||
>
|
||||
{def.name}
|
||||
</Text>
|
||||
<Text size="xs" color="muted">
|
||||
{def.description}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Focusable>
|
||||
);
|
||||
})}
|
||||
<Icon size={16} />
|
||||
<Column gap="1">
|
||||
<Text
|
||||
size="sm"
|
||||
weight={selectedDef?.type === def.type ? 'bold' : undefined}
|
||||
>
|
||||
{def.name}
|
||||
</Text>
|
||||
<Text size="xs" color="muted">
|
||||
{def.description}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Focusable>
|
||||
);
|
||||
})}
|
||||
</Column>
|
||||
))}
|
||||
</Column>
|
||||
) : (
|
||||
<Column alignItems="center" justifyContent="center" height="100%">
|
||||
|
||||
@@ -1,30 +1,47 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { TextBlock } from '@/app/(main)/boards/TextBlock';
|
||||
import { BoardFunnel } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/BoardFunnel';
|
||||
import { LinkMetricsBar } from '@/app/(main)/links/[linkId]/LinkMetricsBar';
|
||||
import { PixelMetricsBar } from '@/app/(main)/pixels/[pixelId]/PixelMetricsBar';
|
||||
import { BoardGoal } from '@/app/(main)/websites/[websiteId]/(reports)/goals/BoardGoal';
|
||||
import { BoardRevenueChart } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueChart';
|
||||
import { BoardRevenueMetricsBar } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsBar';
|
||||
import { BoardRevenueMetricsTable } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/BoardRevenueMetricsTable';
|
||||
import { BoardUTM } from '@/app/(main)/websites/[websiteId]/(reports)/utm/BoardUTM';
|
||||
import { EventsMetricsBar } from '@/app/(main)/websites/[websiteId]/events/EventsMetricsBar';
|
||||
import { BoardRealtimeActiveUsers } from '@/app/(main)/websites/[websiteId]/realtime/BoardRealtimeActiveUsers';
|
||||
import { BoardRealtimeChart } from '@/app/(main)/websites/[websiteId]/realtime/BoardRealtimeChart';
|
||||
import { BoardRealtimeMetricsBar } from '@/app/(main)/websites/[websiteId]/realtime/BoardRealtimeMetricsBar';
|
||||
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
|
||||
import { WebsiteMetricsBar } from '@/app/(main)/websites/[websiteId]/WebsiteMetricsBar';
|
||||
import {
|
||||
Activity,
|
||||
Calendar,
|
||||
ChartColumnBig,
|
||||
ChartPie,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Globe,
|
||||
PanelTop,
|
||||
Sheet,
|
||||
Target,
|
||||
Users,
|
||||
} from '@/components/icons';
|
||||
import { EventsChart } from '@/components/metrics/EventsChart';
|
||||
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||
import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
|
||||
import { WorldMap } from '@/components/metrics/WorldMap';
|
||||
import { CURRENCIES, DEFAULT_CURRENCY } from '@/lib/constants';
|
||||
|
||||
export interface ConfigField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'select' | 'number' | 'text' | 'textarea';
|
||||
type: 'select' | 'number' | 'text' | 'textarea' | 'report';
|
||||
options?: { label: string; value: string }[];
|
||||
optionsByEntityType?: Record<string, { label: string; value: string }[]>;
|
||||
defaultValue?: any;
|
||||
reportType?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface ComponentDefinition {
|
||||
@@ -32,6 +49,7 @@ export interface ComponentDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
group?: string;
|
||||
icon: ComponentType<any>;
|
||||
component: ComponentType<any>;
|
||||
componentByEntityType?: Record<string, ComponentType<any>>;
|
||||
@@ -76,18 +94,13 @@ const METRIC_TYPES = [
|
||||
const PIXEL_LINK_METRIC_TYPES = METRIC_TYPES.filter(({ value }) =>
|
||||
[
|
||||
'referrer',
|
||||
'country',
|
||||
'region',
|
||||
'city',
|
||||
'channel',
|
||||
'browser',
|
||||
'os',
|
||||
'device',
|
||||
'query',
|
||||
'utmSource',
|
||||
'utmMedium',
|
||||
'utmCampaign',
|
||||
'utmContent',
|
||||
'utmTerm',
|
||||
'country',
|
||||
'region',
|
||||
'city',
|
||||
].includes(value),
|
||||
);
|
||||
|
||||
@@ -97,6 +110,26 @@ const LIMIT_OPTIONS = [
|
||||
{ label: '20', value: '20' },
|
||||
];
|
||||
|
||||
const UTM_PARAM_OPTIONS = [
|
||||
{ label: 'Source', value: 'utm_source' },
|
||||
{ label: 'Medium', value: 'utm_medium' },
|
||||
{ label: 'Campaign', value: 'utm_campaign' },
|
||||
{ label: 'Content', value: 'utm_content' },
|
||||
{ label: 'Term', value: 'utm_term' },
|
||||
];
|
||||
|
||||
const REVENUE_METRIC_TYPE_OPTIONS = [
|
||||
{ label: 'Referrers', value: 'referrer' },
|
||||
{ label: 'Channels', value: 'channel' },
|
||||
{ label: 'Countries', value: 'country' },
|
||||
{ label: 'Regions', value: 'region' },
|
||||
];
|
||||
|
||||
const CURRENCY_OPTIONS = CURRENCIES.map(({ id, name }) => ({
|
||||
label: `${id} - ${name}`,
|
||||
value: id,
|
||||
}));
|
||||
|
||||
const PixelMetricsBarAdapter = ({ websiteId }: { websiteId?: string }) =>
|
||||
websiteId ? <PixelMetricsBar pixelId={websiteId} /> : null;
|
||||
|
||||
@@ -110,6 +143,7 @@ const componentDefinitions: ComponentDefinition[] = [
|
||||
name: 'Metrics bar',
|
||||
description: 'Key metrics: views, visitors, bounces, time on site',
|
||||
category: 'overview',
|
||||
group: 'Traffic',
|
||||
icon: PanelTop,
|
||||
component: WebsiteMetricsBar,
|
||||
componentByEntityType: {
|
||||
@@ -117,14 +151,177 @@ const componentDefinitions: ComponentDefinition[] = [
|
||||
link: LinkMetricsBarAdapter,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'EventsMetricsBar',
|
||||
name: 'Event metrics bar',
|
||||
description: 'Key metrics: visitors, visits, events, unique events',
|
||||
category: 'overview',
|
||||
group: 'Events',
|
||||
icon: PanelTop,
|
||||
component: EventsMetricsBar,
|
||||
},
|
||||
{
|
||||
type: 'Goal',
|
||||
name: 'Goal',
|
||||
description: 'Conversion progress for a saved goal',
|
||||
category: 'overview',
|
||||
group: 'Behavior',
|
||||
icon: Target,
|
||||
component: BoardGoal,
|
||||
configFields: [
|
||||
{
|
||||
name: 'reportId',
|
||||
label: 'Saved goal',
|
||||
type: 'report',
|
||||
reportType: 'goal',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'Funnel',
|
||||
name: 'Funnel',
|
||||
description: 'Step conversion for a saved funnel',
|
||||
category: 'overview',
|
||||
group: 'Behavior',
|
||||
icon: GitBranch,
|
||||
component: BoardFunnel,
|
||||
configFields: [
|
||||
{
|
||||
name: 'reportId',
|
||||
label: 'Saved funnel',
|
||||
type: 'report',
|
||||
reportType: 'funnel',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'WebsiteChart',
|
||||
name: 'Visitors chart',
|
||||
description: 'Page views and visitors over time',
|
||||
category: 'overview',
|
||||
group: 'Traffic',
|
||||
icon: ChartColumnBig,
|
||||
component: WebsiteChart,
|
||||
},
|
||||
{
|
||||
type: 'UTM',
|
||||
name: 'UTM',
|
||||
description: 'List and pie chart for one UTM parameter',
|
||||
category: 'visualization',
|
||||
group: 'Growth',
|
||||
icon: ChartPie,
|
||||
component: BoardUTM,
|
||||
defaultProps: { param: 'utm_source', limit: 10 },
|
||||
configFields: [
|
||||
{
|
||||
name: 'param',
|
||||
label: 'UTM param',
|
||||
type: 'select',
|
||||
options: UTM_PARAM_OPTIONS,
|
||||
defaultValue: 'utm_source',
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
label: 'Rows',
|
||||
type: 'select',
|
||||
options: LIMIT_OPTIONS,
|
||||
defaultValue: '10',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'RealtimeHeader',
|
||||
name: 'Realtime metrics bar',
|
||||
description: 'Live views, visitors, events, and countries',
|
||||
category: 'overview',
|
||||
group: 'Realtime',
|
||||
icon: PanelTop,
|
||||
component: BoardRealtimeMetricsBar,
|
||||
},
|
||||
{
|
||||
type: 'RealtimeChart',
|
||||
name: 'Realtime chart',
|
||||
description: 'Live visitors and page views by minute',
|
||||
category: 'visualization',
|
||||
group: 'Realtime',
|
||||
icon: Activity,
|
||||
component: BoardRealtimeChart,
|
||||
},
|
||||
{
|
||||
type: 'RealtimeActiveUsers',
|
||||
name: 'Active users',
|
||||
description: 'Current visitors online',
|
||||
category: 'overview',
|
||||
group: 'Realtime',
|
||||
icon: Users,
|
||||
component: BoardRealtimeActiveUsers,
|
||||
},
|
||||
{
|
||||
type: 'RevenueMetricsBar',
|
||||
name: 'Revenue metrics bar',
|
||||
description: 'Revenue summary: total, AOV, ARPU, orders, and customers',
|
||||
category: 'overview',
|
||||
group: 'Revenue',
|
||||
icon: PanelTop,
|
||||
component: BoardRevenueMetricsBar,
|
||||
defaultProps: { currency: DEFAULT_CURRENCY },
|
||||
configFields: [
|
||||
{
|
||||
name: 'currency',
|
||||
label: 'Currency',
|
||||
type: 'select',
|
||||
options: CURRENCY_OPTIONS,
|
||||
defaultValue: DEFAULT_CURRENCY,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'RevenueChart',
|
||||
name: 'Revenue chart',
|
||||
description: 'Revenue over time',
|
||||
category: 'visualization',
|
||||
group: 'Revenue',
|
||||
icon: ChartColumnBig,
|
||||
component: BoardRevenueChart,
|
||||
defaultProps: { currency: DEFAULT_CURRENCY },
|
||||
configFields: [
|
||||
{
|
||||
name: 'currency',
|
||||
label: 'Currency',
|
||||
type: 'select',
|
||||
options: CURRENCY_OPTIONS,
|
||||
defaultValue: DEFAULT_CURRENCY,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'RevenueMetricsTable',
|
||||
name: 'Revenue metrics table',
|
||||
description: 'Revenue breakdown by source or location',
|
||||
category: 'tables',
|
||||
group: 'Revenue',
|
||||
icon: Sheet,
|
||||
component: BoardRevenueMetricsTable,
|
||||
defaultProps: { type: 'referrer', currency: DEFAULT_CURRENCY },
|
||||
configFields: [
|
||||
{
|
||||
name: 'type',
|
||||
label: 'Metric type',
|
||||
type: 'select',
|
||||
options: REVENUE_METRIC_TYPE_OPTIONS,
|
||||
defaultValue: 'referrer',
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
label: 'Currency',
|
||||
type: 'select',
|
||||
options: CURRENCY_OPTIONS,
|
||||
defaultValue: DEFAULT_CURRENCY,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Tables
|
||||
{
|
||||
@@ -132,6 +329,7 @@ const componentDefinitions: ComponentDefinition[] = [
|
||||
name: 'Metrics table',
|
||||
description: 'Table of metrics by dimension',
|
||||
category: 'tables',
|
||||
group: 'Traffic',
|
||||
icon: Sheet,
|
||||
component: MetricsTable,
|
||||
defaultProps: { type: 'path', limit: 10 },
|
||||
@@ -163,6 +361,7 @@ const componentDefinitions: ComponentDefinition[] = [
|
||||
name: 'World map',
|
||||
description: 'Geographic distribution of visitors',
|
||||
category: 'visualization',
|
||||
group: 'Traffic',
|
||||
icon: Globe,
|
||||
component: WorldMap,
|
||||
},
|
||||
@@ -171,6 +370,7 @@ const componentDefinitions: ComponentDefinition[] = [
|
||||
name: 'Weekly traffic',
|
||||
description: 'Traffic heatmap by day and hour',
|
||||
category: 'visualization',
|
||||
group: 'Traffic',
|
||||
icon: Calendar,
|
||||
component: WeeklyTraffic,
|
||||
},
|
||||
@@ -179,6 +379,7 @@ const componentDefinitions: ComponentDefinition[] = [
|
||||
name: 'Events chart',
|
||||
description: 'Custom events over time',
|
||||
category: 'visualization',
|
||||
group: 'Events',
|
||||
icon: ChartPie,
|
||||
component: EventsChart,
|
||||
},
|
||||
@@ -189,6 +390,7 @@ const componentDefinitions: ComponentDefinition[] = [
|
||||
name: 'Text',
|
||||
description: 'Free-form text content',
|
||||
category: 'content',
|
||||
group: 'Content',
|
||||
icon: FileText,
|
||||
component: TextBlock,
|
||||
requiresWebsite: false,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Text } from '@umami/react-zen';
|
||||
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useReportQuery } from '@/components/hooks/queries/useReportQuery';
|
||||
import { Funnel } from './Funnel';
|
||||
|
||||
export function BoardFunnel({ websiteId, reportId }: { websiteId: string; reportId?: string }) {
|
||||
const { data, isLoading, error, isFetching } = useReportQuery(reportId || '');
|
||||
|
||||
if (!reportId) {
|
||||
return (
|
||||
<EmptyPlaceholder title="Select a saved funnel" description="Choose an existing funnel." />
|
||||
);
|
||||
}
|
||||
|
||||
if (data && (data.type !== 'funnel' || data.websiteId !== websiteId)) {
|
||||
return (
|
||||
<EmptyPlaceholder
|
||||
title="Funnel unavailable"
|
||||
description="This saved funnel is no longer available for the selected website."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
|
||||
{data ? (
|
||||
<Funnel
|
||||
id={data.id}
|
||||
name={data.name}
|
||||
type={data.type}
|
||||
parameters={data.parameters}
|
||||
websiteId={websiteId}
|
||||
allowEdit={false}
|
||||
/>
|
||||
) : (
|
||||
<Text color="muted">Funnel unavailable</Text>
|
||||
)}
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,17 @@ interface FunnelProps {
|
||||
type: string;
|
||||
parameters: Record<string, any>;
|
||||
websiteId: string;
|
||||
allowEdit?: boolean;
|
||||
}
|
||||
|
||||
export function Funnel({ id, name, type, parameters, websiteId }: FunnelProps) {
|
||||
export function Funnel({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
parameters,
|
||||
websiteId,
|
||||
allowEdit = true,
|
||||
}: FunnelProps) {
|
||||
const { t, labels } = useMessages();
|
||||
const { pathname } = useNavigation();
|
||||
const isSharePage = pathname.includes('/share/');
|
||||
@@ -39,7 +47,7 @@ export function Funnel({ id, name, type, parameters, websiteId }: FunnelProps) {
|
||||
</Text>
|
||||
</Row>
|
||||
</Column>
|
||||
{!isSharePage && (
|
||||
{allowEdit && !isSharePage && (
|
||||
<Column>
|
||||
<ReportEditButton
|
||||
id={id}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Text } from '@umami/react-zen';
|
||||
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useDateRange } from '@/components/hooks';
|
||||
import { useReportQuery } from '@/components/hooks/queries/useReportQuery';
|
||||
import { Goal } from './Goal';
|
||||
|
||||
export function BoardGoal({ websiteId, reportId }: { websiteId: string; reportId?: string }) {
|
||||
const {
|
||||
dateRange: { startDate, endDate },
|
||||
} = useDateRange();
|
||||
const { data, isLoading, error, isFetching } = useReportQuery(reportId || '');
|
||||
|
||||
if (!reportId) {
|
||||
return <EmptyPlaceholder title="Select a saved goal" description="Choose an existing goal." />;
|
||||
}
|
||||
|
||||
if (data && (data.type !== 'goal' || data.websiteId !== websiteId)) {
|
||||
return (
|
||||
<EmptyPlaceholder
|
||||
title="Goal unavailable"
|
||||
description="This saved goal is no longer available for the selected website."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
|
||||
{data ? (
|
||||
<Goal
|
||||
id={data.id}
|
||||
name={data.name}
|
||||
type={data.type}
|
||||
parameters={data.parameters}
|
||||
websiteId={websiteId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
allowEdit={false}
|
||||
/>
|
||||
) : (
|
||||
<Text color="muted">Goal unavailable</Text>
|
||||
)}
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
@@ -19,11 +19,21 @@ export interface GoalProps {
|
||||
websiteId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
allowEdit?: boolean;
|
||||
}
|
||||
|
||||
export type GoalData = { num: number; total: number };
|
||||
|
||||
export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
|
||||
export function Goal({
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
parameters,
|
||||
websiteId,
|
||||
startDate,
|
||||
endDate,
|
||||
allowEdit = true,
|
||||
}: GoalProps) {
|
||||
const { t, labels } = useMessages();
|
||||
const { pathname } = useNavigation();
|
||||
const isSharePage = pathname.includes('/share/');
|
||||
@@ -47,7 +57,7 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
|
||||
</Text>
|
||||
</Row>
|
||||
</Column>
|
||||
{!isSharePage && (
|
||||
{allowEdit && !isSharePage && (
|
||||
<Column>
|
||||
<ReportEditButton
|
||||
id={id}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useDateRange, useTimezone } from '@/components/hooks';
|
||||
import { useRevenueChartQuery } from '@/components/hooks/queries/useRevenueChartQuery';
|
||||
import { RevenueChart } from './RevenueChart';
|
||||
import { useBoardRevenueCurrency } from './useBoardRevenueCurrency';
|
||||
|
||||
export function BoardRevenueChart({
|
||||
websiteId,
|
||||
currency: selectedCurrency,
|
||||
}: {
|
||||
websiteId: string;
|
||||
currency?: string;
|
||||
}) {
|
||||
const currency = useBoardRevenueCurrency(selectedCurrency);
|
||||
const { timezone } = useTimezone();
|
||||
const {
|
||||
dateRange: { startDate, endDate, unit },
|
||||
} = useDateRange({ timezone });
|
||||
const { data, isLoading, isFetching, error } = useRevenueChartQuery(websiteId, currency);
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
|
||||
{data && (
|
||||
<RevenueChart
|
||||
data={data.chart}
|
||||
unit={unit}
|
||||
minDate={startDate}
|
||||
maxDate={endDate}
|
||||
currency={currency}
|
||||
/>
|
||||
)}
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useDateRange } from '@/components/hooks';
|
||||
import { useRevenueStatsQuery } from '@/components/hooks/queries/useRevenueStatsQuery';
|
||||
import { RevenueMetricsBar } from './RevenueMetricsBar';
|
||||
import { useBoardRevenueCurrency } from './useBoardRevenueCurrency';
|
||||
|
||||
export function BoardRevenueMetricsBar({
|
||||
websiteId,
|
||||
currency: selectedCurrency,
|
||||
}: {
|
||||
websiteId: string;
|
||||
currency?: string;
|
||||
}) {
|
||||
const currency = useBoardRevenueCurrency(selectedCurrency);
|
||||
const { compare } = useDateRange();
|
||||
const { data, isLoading, isFetching, error } = useRevenueStatsQuery({
|
||||
websiteId,
|
||||
currency,
|
||||
compare,
|
||||
});
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
|
||||
{data && <RevenueMetricsBar data={data} currency={currency} />}
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useDateRange, useMessages } from '@/components/hooks';
|
||||
import type { RevenueMetricType } from '@/components/hooks/queries/useRevenueMetricsQuery';
|
||||
import { useRevenueStatsQuery } from '@/components/hooks/queries/useRevenueStatsQuery';
|
||||
import { RevenueMetricsTable } from './RevenueMetricsTable';
|
||||
import { useBoardRevenueCurrency } from './useBoardRevenueCurrency';
|
||||
|
||||
function getRevenueTableTitle(type: RevenueMetricType, labels: Record<string, string>, t: (key: string) => string) {
|
||||
switch (type) {
|
||||
case 'referrer':
|
||||
return t(labels.referrer);
|
||||
case 'channel':
|
||||
return t(labels.channel);
|
||||
case 'country':
|
||||
return t(labels.country);
|
||||
case 'region':
|
||||
return t(labels.region);
|
||||
}
|
||||
}
|
||||
|
||||
export function BoardRevenueMetricsTable({
|
||||
websiteId,
|
||||
type = 'referrer',
|
||||
currency: selectedCurrency,
|
||||
}: {
|
||||
websiteId: string;
|
||||
type?: RevenueMetricType;
|
||||
currency?: string;
|
||||
}) {
|
||||
const currency = useBoardRevenueCurrency(selectedCurrency);
|
||||
const { compare } = useDateRange();
|
||||
const { t, labels } = useMessages();
|
||||
const { data, isLoading, isFetching, error } = useRevenueStatsQuery({
|
||||
websiteId,
|
||||
currency,
|
||||
compare,
|
||||
});
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
|
||||
{data && (
|
||||
<RevenueMetricsTable
|
||||
websiteId={websiteId}
|
||||
currency={currency}
|
||||
type={type}
|
||||
title={getRevenueTableTitle(type, labels, t)}
|
||||
totalRevenue={Number(data.sum) || 0}
|
||||
enabled={true}
|
||||
/>
|
||||
)}
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { CURRENCY_CONFIG, DEFAULT_CURRENCY } from '@/lib/constants';
|
||||
import { getItem } from '@/lib/storage';
|
||||
|
||||
export function useBoardRevenueCurrency(currency?: string) {
|
||||
return currency || getItem(CURRENCY_CONFIG) || process.env.defaultCurrency || DEFAULT_CURRENCY;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Column, Heading, Text } from '@umami/react-zen';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { PieChart } from '@/components/charts/PieChart';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useDateRange, useMessages, useResultQuery } from '@/components/hooks';
|
||||
import { ListTable } from '@/components/metrics/ListTable';
|
||||
import { CHART_COLORS } from '@/lib/constants';
|
||||
|
||||
const STACKED_LAYOUT_WIDTH = 900;
|
||||
|
||||
export function BoardUTM({
|
||||
websiteId,
|
||||
param = 'utm_source',
|
||||
limit = 10,
|
||||
}: {
|
||||
websiteId: string;
|
||||
param?: string;
|
||||
limit?: number | string;
|
||||
}) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [isStacked, setIsStacked] = useState(false);
|
||||
const {
|
||||
dateRange: { startDate, endDate },
|
||||
} = useDateRange();
|
||||
const { t, labels } = useMessages();
|
||||
const { data, error, isLoading } = useResultQuery<any>('utm', {
|
||||
websiteId,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
const itemLimit = Number(limit) || 10;
|
||||
const items = (data?.[param] ?? []).slice(0, itemLimit);
|
||||
const total = items.reduce((sum, { views }) => {
|
||||
return +sum + +views;
|
||||
}, 0);
|
||||
const chartData = {
|
||||
labels: items.map(({ utm }) => utm),
|
||||
datasets: [
|
||||
{
|
||||
data: items.map(({ views }) => views),
|
||||
backgroundColor: CHART_COLORS,
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const updateLayout = () => {
|
||||
const width = wrapperRef.current?.clientWidth ?? 0;
|
||||
|
||||
setIsStacked(current => {
|
||||
const next = width > 0 && width < STACKED_LAYOUT_WIDTH;
|
||||
return current === next ? current : next;
|
||||
});
|
||||
};
|
||||
|
||||
updateLayout();
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateLayout) : null;
|
||||
|
||||
if (wrapperRef.current && resizeObserver) {
|
||||
resizeObserver.observe(wrapperRef.current);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateLayout);
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', updateLayout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LoadingPanel data={items} isLoading={isLoading} error={error} minHeight="300px">
|
||||
<div ref={wrapperRef}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isStacked
|
||||
? 'minmax(0, 1fr)'
|
||||
: 'minmax(0, 1fr) minmax(0, 1fr)',
|
||||
gap: 24,
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
<Column style={{ minWidth: 0 }}>
|
||||
<Heading>
|
||||
<Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>
|
||||
</Heading>
|
||||
<ListTable
|
||||
metric={t(labels.views)}
|
||||
data={items.map(({ utm, views }) => ({
|
||||
label: utm,
|
||||
count: views,
|
||||
percent: total ? (views / total) * 100 : 0,
|
||||
}))}
|
||||
/>
|
||||
</Column>
|
||||
<Column style={{ minWidth: 0, overflow: 'hidden' }}>
|
||||
<PieChart
|
||||
type="doughnut"
|
||||
chartData={chartData}
|
||||
height={isStacked ? '240px' : '300px'}
|
||||
/>
|
||||
</Column>
|
||||
</div>
|
||||
</div>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,70 @@
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { useWebsiteSessionStatsQuery } from '@/components/hooks/queries/useWebsiteSessionStatsQuery';
|
||||
import { useDateRange, useMessages } from '@/components/hooks';
|
||||
import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery';
|
||||
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
|
||||
export function EventsMetricsBar({ websiteId }: { websiteId: string }) {
|
||||
const { t, labels } = useMessages();
|
||||
const { data, isLoading, isFetching, error } = useWebsiteSessionStatsQuery(websiteId);
|
||||
const { isAllTime } = useDateRange();
|
||||
const { t, labels, getErrorMessage } = useMessages();
|
||||
const { data, isLoading, isFetching, error } = useEventStatsQuery({
|
||||
websiteId,
|
||||
});
|
||||
|
||||
const { events, visitors, visits, uniqueEvents, comparison } = data || {};
|
||||
|
||||
const metrics = data
|
||||
? [
|
||||
{
|
||||
value: visitors,
|
||||
label: t(labels.visitors),
|
||||
change: visitors - comparison.visitors,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
{
|
||||
value: visits,
|
||||
label: t(labels.visits),
|
||||
change: visits - comparison.visits,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
{
|
||||
value: events,
|
||||
label: t(labels.events),
|
||||
change: events - comparison.events,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
{
|
||||
value: uniqueEvents,
|
||||
label: t(labels.uniqueEvents),
|
||||
change: uniqueEvents - comparison.uniqueEvents,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
|
||||
{data && (
|
||||
<MetricsBar>
|
||||
<MetricCard
|
||||
value={data?.visitors?.value}
|
||||
label={t(labels.visitors)}
|
||||
formatValue={formatLongNumber}
|
||||
/>
|
||||
<MetricCard
|
||||
value={data?.visits?.value}
|
||||
label={t(labels.visits)}
|
||||
formatValue={formatLongNumber}
|
||||
/>
|
||||
<MetricCard
|
||||
value={data?.pageviews?.value}
|
||||
label={t(labels.views)}
|
||||
formatValue={formatLongNumber}
|
||||
/>
|
||||
<MetricCard
|
||||
value={data?.events?.value}
|
||||
label={t(labels.events)}
|
||||
formatValue={formatLongNumber}
|
||||
/>
|
||||
</MetricsBar>
|
||||
)}
|
||||
<LoadingPanel
|
||||
data={metrics}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
error={getErrorMessage(error)}
|
||||
minHeight="136px"
|
||||
>
|
||||
<MetricsBar>
|
||||
{metrics?.map(({ label, value, change, formatValue }) => {
|
||||
return (
|
||||
<MetricCard
|
||||
key={label}
|
||||
value={value}
|
||||
label={label}
|
||||
change={change}
|
||||
formatValue={formatValue}
|
||||
showChange={!isAllTime}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MetricsBar>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
@@ -3,90 +3,30 @@ import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen';
|
||||
import { type Key, useState } from 'react';
|
||||
import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal';
|
||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useDateRange, useMessages } from '@/components/hooks';
|
||||
import { useEventStatsQuery } from '@/components/hooks/queries/useEventStatsQuery';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { EventsChart } from '@/components/metrics/EventsChart';
|
||||
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
||||
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
import { getItem, setItem } from '@/lib/storage';
|
||||
import { EventProperties } from './EventProperties';
|
||||
import { EventsDataTable } from './EventsDataTable';
|
||||
import { EventsMetricsBar } from './EventsMetricsBar';
|
||||
|
||||
const KEY_NAME = 'umami.events.tab';
|
||||
|
||||
export function EventsPage({ websiteId }) {
|
||||
const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart');
|
||||
const { isAllTime } = useDateRange();
|
||||
const { t, labels, getErrorMessage } = useMessages();
|
||||
const { data, isLoading, isFetching, error } = useEventStatsQuery({
|
||||
websiteId,
|
||||
});
|
||||
const { t, labels } = useMessages();
|
||||
|
||||
const handleSelect = (value: Key) => {
|
||||
setItem(KEY_NAME, value);
|
||||
setTab(value);
|
||||
};
|
||||
|
||||
const { events, visitors, visits, uniqueEvents, comparison } = data || {};
|
||||
|
||||
const metrics = data
|
||||
? [
|
||||
{
|
||||
value: visitors,
|
||||
label: t(labels.visitors),
|
||||
change: visitors - comparison.visitors,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
{
|
||||
value: visits,
|
||||
label: t(labels.visits),
|
||||
change: visits - comparison.visits,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
{
|
||||
value: events,
|
||||
label: t(labels.events),
|
||||
change: events - comparison.events,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
{
|
||||
value: uniqueEvents,
|
||||
label: t(labels.uniqueEvents),
|
||||
change: uniqueEvents - comparison.uniqueEvents,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Column gap="3">
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<LoadingPanel
|
||||
data={metrics}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
error={getErrorMessage(error)}
|
||||
minHeight="136px"
|
||||
>
|
||||
<MetricsBar>
|
||||
{metrics?.map(({ label, value, change, formatValue }) => {
|
||||
return (
|
||||
<MetricCard
|
||||
key={label}
|
||||
value={value}
|
||||
label={label}
|
||||
change={change}
|
||||
formatValue={formatValue}
|
||||
showChange={!isAllTime}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MetricsBar>
|
||||
</LoadingPanel>
|
||||
<EventsMetricsBar websiteId={websiteId} />
|
||||
<Panel minWidth="0" width="100%" style={{ overflow: 'hidden' }}>
|
||||
<Tabs
|
||||
selectedKey={tab}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Column, Text } from '@umami/react-zen';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useActiveUsersQuery, useMessages } from '@/components/hooks';
|
||||
import { REALTIME_INTERVAL } from '@/lib/constants';
|
||||
|
||||
export function BoardRealtimeActiveUsers({ websiteId }: { websiteId: string }) {
|
||||
const { t, labels } = useMessages();
|
||||
const { data, isLoading, error } = useActiveUsersQuery(websiteId, {
|
||||
refetchInterval: REALTIME_INTERVAL,
|
||||
});
|
||||
const count = data?.visitors ?? 0;
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="136px">
|
||||
<Column alignItems="center" justifyContent="center" height="100%" gap="2">
|
||||
<Text size="6xl" weight="bold">
|
||||
{count}
|
||||
</Text>
|
||||
<Text size="sm" color="muted">
|
||||
{t(labels.online)}
|
||||
</Text>
|
||||
</Column>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useRealtimeQuery } from '@/components/hooks';
|
||||
import { RealtimeChart } from '@/components/metrics/RealtimeChart';
|
||||
|
||||
export function BoardRealtimeChart({ websiteId }: { websiteId: string }) {
|
||||
const { data, isLoading, error } = useRealtimeQuery(websiteId);
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="320px">
|
||||
<RealtimeChart data={data} unit="minute" />
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useRealtimeQuery } from '@/components/hooks';
|
||||
import { RealtimeHeader } from './RealtimeHeader';
|
||||
|
||||
export function BoardRealtimeMetricsBar({ websiteId }: { websiteId: string }) {
|
||||
const { data, isLoading, error } = useRealtimeQuery(websiteId);
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="136px">
|
||||
<RealtimeHeader data={data} />
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ repo
|
||||
|
||||
const report = await getReport(reportId);
|
||||
|
||||
if (!report) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!(await canViewReport(auth, report))) {
|
||||
return unauthorized();
|
||||
}
|
||||
|
||||
@@ -4,33 +4,13 @@ import { parseRequest } from '@/lib/request';
|
||||
import { json, unauthorized } from '@/lib/response';
|
||||
import { pagingParams, reportSchema, reportTypeParam } from '@/lib/schema';
|
||||
import {
|
||||
getReportSection,
|
||||
canUpdateWebsite,
|
||||
canViewAuthenticatedWebsite,
|
||||
canViewWebsiteSection,
|
||||
} from '@/permissions';
|
||||
import type { ShareSection } from '@/permissions';
|
||||
import { createReport, getReports } from '@/queries/prisma';
|
||||
|
||||
function getReportSection(type?: z.infer<typeof reportTypeParam>): ShareSection | null {
|
||||
switch (type) {
|
||||
case 'attribution':
|
||||
case 'breakdown':
|
||||
case 'performance':
|
||||
case 'retention':
|
||||
case 'revenue':
|
||||
case 'utm':
|
||||
return type;
|
||||
case 'funnel':
|
||||
return 'funnels';
|
||||
case 'goal':
|
||||
return 'goals';
|
||||
case 'journey':
|
||||
return 'journeys';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const schema = z.object({
|
||||
websiteId: z.uuid(),
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import type { BoardEntityType } from './boards';
|
||||
|
||||
export const BOARD_COMPONENT_COMPATIBILITY_MATRIX = {
|
||||
EventsMetricsBar: ['website'],
|
||||
EventsChart: ['website'],
|
||||
Funnel: ['website'],
|
||||
Goal: ['website'],
|
||||
RealtimeActiveUsers: ['website'],
|
||||
RealtimeHeader: ['website'],
|
||||
RealtimeChart: ['website'],
|
||||
RevenueMetricsBar: ['website'],
|
||||
RevenueChart: ['website'],
|
||||
RevenueMetricsTable: ['website'],
|
||||
UTM: ['website'],
|
||||
WeeklyTraffic: ['website'],
|
||||
} as const satisfies Partial<Record<string, readonly BoardEntityType[]>>;
|
||||
|
||||
export function getSupportedBoardComponentEntityTypes(componentType: string) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { getComponentDefinition } from '@/app/(main)/boards/boardComponentRegistry';
|
||||
import {
|
||||
BOARD_COMPONENT_COMPATIBILITY_MATRIX,
|
||||
getSupportedBoardComponentEntityTypes,
|
||||
@@ -14,14 +15,101 @@ test('isBoardComponentSupported rejects events chart on pixel and link boards',
|
||||
expect(isBoardComponentSupported('EventsChart', BOARD_ENTITY_TYPES.link)).toBe(false);
|
||||
});
|
||||
|
||||
test('isBoardComponentSupported rejects event metrics bar and weekly traffic on pixel and link boards', () => {
|
||||
expect(isBoardComponentSupported('EventsMetricsBar', BOARD_ENTITY_TYPES.pixel)).toBe(false);
|
||||
expect(isBoardComponentSupported('EventsMetricsBar', BOARD_ENTITY_TYPES.link)).toBe(false);
|
||||
expect(isBoardComponentSupported('WeeklyTraffic', BOARD_ENTITY_TYPES.pixel)).toBe(false);
|
||||
expect(isBoardComponentSupported('WeeklyTraffic', BOARD_ENTITY_TYPES.link)).toBe(false);
|
||||
});
|
||||
|
||||
test('board component compatibility matrix defines website-only components explicitly', () => {
|
||||
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.EventsMetricsBar).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.EventsChart).toEqual([BOARD_ENTITY_TYPES.website]);
|
||||
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.Funnel).toEqual([BOARD_ENTITY_TYPES.website]);
|
||||
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.Goal).toEqual([BOARD_ENTITY_TYPES.website]);
|
||||
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RealtimeActiveUsers).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RealtimeHeader).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RealtimeChart).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RevenueMetricsBar).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RevenueChart).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.RevenueMetricsTable).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.UTM).toEqual([BOARD_ENTITY_TYPES.website]);
|
||||
expect(BOARD_COMPONENT_COMPATIBILITY_MATRIX.WeeklyTraffic).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(getSupportedBoardComponentEntityTypes('EventsMetricsBar')).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(getSupportedBoardComponentEntityTypes('EventsChart')).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(getSupportedBoardComponentEntityTypes('Funnel')).toEqual([BOARD_ENTITY_TYPES.website]);
|
||||
expect(getSupportedBoardComponentEntityTypes('Goal')).toEqual([BOARD_ENTITY_TYPES.website]);
|
||||
expect(getSupportedBoardComponentEntityTypes('RealtimeActiveUsers')).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(getSupportedBoardComponentEntityTypes('RealtimeHeader')).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(getSupportedBoardComponentEntityTypes('RealtimeChart')).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(getSupportedBoardComponentEntityTypes('RevenueMetricsBar')).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(getSupportedBoardComponentEntityTypes('RevenueChart')).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(getSupportedBoardComponentEntityTypes('RevenueMetricsTable')).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
expect(getSupportedBoardComponentEntityTypes('UTM')).toEqual([BOARD_ENTITY_TYPES.website]);
|
||||
expect(getSupportedBoardComponentEntityTypes('WeeklyTraffic')).toEqual([
|
||||
BOARD_ENTITY_TYPES.website,
|
||||
]);
|
||||
});
|
||||
|
||||
test('isBoardComponentSupported leaves other components available for all board entities', () => {
|
||||
expect(isBoardComponentSupported('WebsiteChart', BOARD_ENTITY_TYPES.pixel)).toBe(true);
|
||||
expect(isBoardComponentSupported('TextBlock', BOARD_ENTITY_TYPES.link)).toBe(true);
|
||||
});
|
||||
|
||||
test('metrics table limits pixel and link boards to overview-page metric types', () => {
|
||||
const definition = getComponentDefinition('MetricsTable');
|
||||
const options = definition?.configFields?.find(field => field.name === 'type')?.optionsByEntityType;
|
||||
|
||||
expect(options?.pixel?.map(option => option.value)).toEqual([
|
||||
'referrer',
|
||||
'channel',
|
||||
'country',
|
||||
'region',
|
||||
'city',
|
||||
'browser',
|
||||
'os',
|
||||
'device',
|
||||
]);
|
||||
expect(options?.link?.map(option => option.value)).toEqual([
|
||||
'referrer',
|
||||
'channel',
|
||||
'country',
|
||||
'region',
|
||||
'city',
|
||||
'browser',
|
||||
'os',
|
||||
'device',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { ENTITY_TYPE } from '@/lib/constants';
|
||||
import { canViewReport, getReportSection } from './report';
|
||||
import { canViewWebsite } from './website';
|
||||
|
||||
vi.mock('./website', () => ({
|
||||
canViewWebsite: vi.fn(),
|
||||
}));
|
||||
|
||||
test('getReportSection maps saved report types to share sections', () => {
|
||||
expect(getReportSection('goal')).toBe('goals');
|
||||
expect(getReportSection('funnel')).toBe('funnels');
|
||||
expect(getReportSection('journey')).toBe('journeys');
|
||||
expect(getReportSection('revenue')).toBe('revenue');
|
||||
expect(getReportSection('heatmap')).toBe(null);
|
||||
});
|
||||
|
||||
test('canViewReport allows board shares to fetch included saved goal reports', async () => {
|
||||
await expect(
|
||||
canViewReport(
|
||||
{
|
||||
shareToken: {
|
||||
shareType: ENTITY_TYPE.board,
|
||||
websiteIds: ['website-1'],
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'report-1',
|
||||
userId: 'owner-1',
|
||||
websiteId: 'website-1',
|
||||
type: 'goal',
|
||||
} as any,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('canViewReport respects share section flags for saved funnel and goal reports', async () => {
|
||||
await expect(
|
||||
canViewReport(
|
||||
{
|
||||
shareToken: {
|
||||
shareType: ENTITY_TYPE.website,
|
||||
websiteId: 'website-1',
|
||||
parameters: {
|
||||
overview: true,
|
||||
funnels: false,
|
||||
goals: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'report-1',
|
||||
userId: 'owner-1',
|
||||
websiteId: 'website-1',
|
||||
type: 'funnel',
|
||||
} as any,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
|
||||
await expect(
|
||||
canViewReport(
|
||||
{
|
||||
shareToken: {
|
||||
shareType: ENTITY_TYPE.website,
|
||||
websiteId: 'website-1',
|
||||
parameters: {
|
||||
overview: true,
|
||||
funnels: false,
|
||||
goals: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'report-2',
|
||||
userId: 'owner-1',
|
||||
websiteId: 'website-1',
|
||||
type: 'goal',
|
||||
} as any,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
test('canViewReport denies share-token access to report types without a share section', async () => {
|
||||
await expect(
|
||||
canViewReport(
|
||||
{
|
||||
shareToken: {
|
||||
shareType: ENTITY_TYPE.website,
|
||||
websiteId: 'website-1',
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'report-1',
|
||||
userId: 'owner-1',
|
||||
websiteId: 'website-1',
|
||||
type: 'heatmap',
|
||||
} as any,
|
||||
),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test('canViewReport falls back to website access for authenticated users', async () => {
|
||||
vi.mocked(canViewWebsite).mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
canViewReport(
|
||||
{
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'user',
|
||||
role: 'user',
|
||||
isAdmin: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'report-1',
|
||||
userId: 'owner-1',
|
||||
websiteId: 'website-1',
|
||||
type: 'heatmap',
|
||||
} as any,
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
@@ -1,8 +1,34 @@
|
||||
import type { Report } from '@/generated/prisma/client';
|
||||
import type { Auth } from '@/lib/types';
|
||||
import type { ShareSection } from './share';
|
||||
import { canViewWebsiteSection } from './share';
|
||||
import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from './website';
|
||||
|
||||
export async function canViewReport(auth: Auth, report: Report) {
|
||||
export function getReportSection(type?: string): ShareSection | null {
|
||||
switch (type) {
|
||||
case 'attribution':
|
||||
case 'breakdown':
|
||||
case 'performance':
|
||||
case 'retention':
|
||||
case 'revenue':
|
||||
case 'utm':
|
||||
return type;
|
||||
case 'funnel':
|
||||
return 'funnels';
|
||||
case 'goal':
|
||||
return 'goals';
|
||||
case 'journey':
|
||||
return 'journeys';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function canViewReport(auth: Auth, report: Report | null) {
|
||||
if (!report) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (auth.user?.isAdmin) {
|
||||
return true;
|
||||
}
|
||||
@@ -11,7 +37,13 @@ export async function canViewReport(auth: Auth, report: Report) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!(await canViewWebsite(auth, report.websiteId));
|
||||
const section = getReportSection(report.type);
|
||||
|
||||
if (section) {
|
||||
return !!(await canViewWebsiteSection(auth, report.websiteId, section));
|
||||
}
|
||||
|
||||
return !!auth.user && !!(await canViewWebsite(auth, report.websiteId));
|
||||
}
|
||||
|
||||
export async function canUpdateReport(auth: Auth, report: Report) {
|
||||
|
||||
Reference in new issue
Block a user