refactor(ui): use feature analytics and saved definition routes
This commit is contained in:
1 parent
e202b740a1
commit
007919b173
41 files changed
+645
-367
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { useModified } from '@/components/hooks/useModified';
|
||||
import { Button, Column, ListItem, Row, Select, Text, TextField } from '@umami/react-zen';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useApi, useMessages } from '@/components/hooks';
|
||||
@@ -157,9 +158,13 @@ export function BoardComponentSelect({
|
||||
const reportFields = selectedDef?.configFields?.filter(
|
||||
field => field.type === 'report' && field.reportType,
|
||||
);
|
||||
const { modified: funnelsModified } = useModified('websites:funnels');
|
||||
const { modified: goalsModified } = useModified('websites:goals');
|
||||
const { data: reportOptionsData, isLoading: isLoadingReportOptions } = useQuery({
|
||||
queryKey: [
|
||||
'board-component-report-options',
|
||||
funnelsModified,
|
||||
goalsModified,
|
||||
{
|
||||
websiteId: resolvedEntityId,
|
||||
reportTypes: reportFields?.map(field => field.reportType).join(','),
|
||||
@@ -170,9 +175,7 @@ export function BoardComponentSelect({
|
||||
|
||||
const entries = await Promise.all(
|
||||
types.map(async type => {
|
||||
const response = await get('/reports', {
|
||||
websiteId: resolvedEntityId,
|
||||
type,
|
||||
const response = await get(`/websites/${resolvedEntityId}/${type}s`, {
|
||||
pageSize: 1000,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Column, Grid } from '@umami/react-zen';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { SectionHeader } from '@/components/common/SectionHeader';
|
||||
import { useMessages, useResultQuery } from '@/components/hooks';
|
||||
import { useAttributionQuery, useMessages } from '@/components/hooks';
|
||||
import { ListTable } from '@/components/metrics/ListTable';
|
||||
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
||||
@@ -26,8 +26,7 @@ export function Attribution({
|
||||
type,
|
||||
step,
|
||||
}: AttributionProps) {
|
||||
const { data, error, isLoading } = useResultQuery<any>(
|
||||
'attribution',
|
||||
const { data, error, isLoading } = useAttributionQuery(
|
||||
{
|
||||
websiteId,
|
||||
startDate,
|
||||
@@ -113,7 +112,7 @@ export function Attribution({
|
||||
<AttributionTable data={data?.utm_medium} title={t(labels.medium)} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<AttributionTable data={data?.utm_cmapaign} title={t(labels.campaigns)} />
|
||||
<AttributionTable data={data?.utm_campaign} title={t(labels.campaigns)} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<AttributionTable data={data?.utm_content} title={t(labels.content)} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Column, DataColumn, DataTable, Text } from '@umami/react-zen';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useFields, useFormat, useMessages, useResultQuery } from '@/components/hooks';
|
||||
import { useBreakdownQuery, useFields, useFormat, useMessages } from '@/components/hooks';
|
||||
import { formatShortTime } from '@/lib/format';
|
||||
|
||||
export interface BreakdownProps {
|
||||
@@ -14,8 +14,7 @@ export function Breakdown({ websiteId, selectedFields = [], startDate, endDate }
|
||||
const { t, labels } = useMessages();
|
||||
const { formatValue } = useFormat();
|
||||
const { fields } = useFields();
|
||||
const { data, error, isLoading } = useResultQuery<any>(
|
||||
'breakdown',
|
||||
const { data, error, isLoading } = useBreakdownQuery(
|
||||
{
|
||||
websiteId,
|
||||
startDate,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm';
|
||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useDateRange, useMessages, useResultQuery } from '@/components/hooks';
|
||||
import { useBreakdownQuery, useDateRange, useMessages } from '@/components/hooks';
|
||||
import { ListCheck } from '@/components/icons';
|
||||
import { DialogButton } from '@/components/input/DialogButton';
|
||||
import { DownloadButton } from '@/components/input/DownloadButton';
|
||||
@@ -15,8 +15,7 @@ export function BreakdownPage({ websiteId }: { websiteId: string }) {
|
||||
dateRange: { startDate, endDate },
|
||||
} = useDateRange();
|
||||
const [fields, setFields] = useState(['path']);
|
||||
const { data } = useResultQuery<any>(
|
||||
'breakdown',
|
||||
const { data } = useBreakdownQuery(
|
||||
{ websiteId, startDate, endDate, fields },
|
||||
{ enabled: !!fields.length },
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Text } from '@umami/react-zen';
|
||||
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useFunnelDefinitionQuery } from '@/components/hooks/queries/useSavedDefinitionsQuery';
|
||||
import { Link2Off } from '@/components/icons';
|
||||
import { useReportQuery } from '@/components/hooks/queries/useReportQuery';
|
||||
import { Funnel } from './Funnel';
|
||||
|
||||
export function BoardFunnel({
|
||||
@@ -14,7 +14,10 @@ export function BoardFunnel({
|
||||
reportId?: string;
|
||||
isPreview?: boolean;
|
||||
}) {
|
||||
const { data, isLoading, error, isFetching } = useReportQuery(reportId || '');
|
||||
const { data, isLoading, error, isFetching } = useFunnelDefinitionQuery(
|
||||
websiteId,
|
||||
reportId || '',
|
||||
);
|
||||
|
||||
if (!reportId) {
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Box, Column, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useMessages, useNavigation, useOperatorLabels, useResultQuery } from '@/components/hooks';
|
||||
import { useFunnelQuery, useMessages, useNavigation, useOperatorLabels } from '@/components/hooks';
|
||||
import { File, User } from '@/components/icons';
|
||||
import { ReportEditButton } from '@/components/input/ReportEditButton';
|
||||
import { SavedDefinitionEditButton } from '@/components/input/SavedDefinitionEditButton';
|
||||
import { ChangeLabel } from '@/components/metrics/ChangeLabel';
|
||||
import { Lightning } from '@/components/svg';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
@@ -22,8 +22,9 @@ export function Funnel({ id, name, type, parameters, websiteId, allowEdit = true
|
||||
const { t, labels } = useMessages();
|
||||
const { pathname } = useNavigation();
|
||||
const isSharePage = pathname.includes('/share/');
|
||||
const { data, error, isLoading } = useResultQuery<Array<FunnelResult>>(type, {
|
||||
const { data, error, isLoading } = useFunnelQuery({
|
||||
websiteId,
|
||||
id,
|
||||
...parameters,
|
||||
});
|
||||
|
||||
@@ -41,7 +42,8 @@ export function Funnel({ id, name, type, parameters, websiteId, allowEdit = true
|
||||
</Column>
|
||||
{allowEdit && !isSharePage && (
|
||||
<Column>
|
||||
<ReportEditButton
|
||||
<SavedDefinitionEditButton
|
||||
websiteId={websiteId}
|
||||
id={id}
|
||||
name={name}
|
||||
type={type}
|
||||
@@ -50,7 +52,7 @@ export function Funnel({ id, name, type, parameters, websiteId, allowEdit = true
|
||||
height="600px"
|
||||
>
|
||||
{({ close }) => <FunnelEditForm id={id} websiteId={websiteId} onClose={close} />}
|
||||
</ReportEditButton>
|
||||
</SavedDefinitionEditButton>
|
||||
</Column>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
@@ -15,7 +15,13 @@ import {
|
||||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { useApi, useMessages, useMobile, useReportQuery, useUpdateQuery } from '@/components/hooks';
|
||||
import {
|
||||
useApi,
|
||||
useFunnelDefinitionQuery,
|
||||
useMessages,
|
||||
useMobile,
|
||||
useUpdateQuery,
|
||||
} from '@/components/hooks';
|
||||
import { Plus, X } from '@/components/icons';
|
||||
import { ActionSelect } from '@/components/input/ActionSelect';
|
||||
import { LookupField } from '@/components/input/LookupField';
|
||||
@@ -160,8 +166,10 @@ export function FunnelEditForm({
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
const { data, isLoading } = useReportQuery(id);
|
||||
const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);
|
||||
const { data, isLoading } = useFunnelDefinitionQuery(websiteId, id);
|
||||
const { mutateAsync, error, isPending, touch } = useUpdateQuery(
|
||||
`/websites/${websiteId}/funnels${id ? `/${id}` : ''}`,
|
||||
);
|
||||
|
||||
const handleSubmit = async ({
|
||||
name,
|
||||
@@ -171,11 +179,10 @@ export function FunnelEditForm({
|
||||
[key: string]: unknown;
|
||||
}) => {
|
||||
await mutateAsync(
|
||||
{ ...data, id, name, type: 'funnel', websiteId, parameters },
|
||||
{ name, description: data?.description, parameters },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
touch('reports:funnel');
|
||||
touch(`report:${id}`);
|
||||
touch('websites:funnels');
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
},
|
||||
@@ -206,7 +213,10 @@ export function FunnelEditForm({
|
||||
label={t(labels.window)}
|
||||
rules={{
|
||||
required: t(labels.required),
|
||||
pattern: { value: /^[1-9][0-9]*$/, message: t(labels.invalidValue) || 'Must be greater than 0' },
|
||||
pattern: {
|
||||
value: /^[1-9][0-9]*$/,
|
||||
message: t(labels.invalidValue) || 'Must be greater than 0',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TextField />
|
||||
|
||||
@@ -4,12 +4,12 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { SectionHeader } from '@/components/common/SectionHeader';
|
||||
import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
|
||||
import { useDateRange, useFunnelsQuery, useNavigation } from '@/components/hooks';
|
||||
import { Funnel } from './Funnel';
|
||||
import { FunnelAddButton } from './FunnelAddButton';
|
||||
|
||||
export function FunnelsPage({ websiteId }: { websiteId: string }) {
|
||||
const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'funnel' });
|
||||
const { data, isLoading, error } = useFunnelsQuery({ websiteId });
|
||||
const {
|
||||
dateRange: { startDate, endDate },
|
||||
} = useDateRange();
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Text } from '@umami/react-zen';
|
||||
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useDateRange } from '@/components/hooks';
|
||||
import { useGoalDefinitionQuery } from '@/components/hooks/queries/useSavedDefinitionsQuery';
|
||||
import { Link2Off } from '@/components/icons';
|
||||
import { useReportQuery } from '@/components/hooks/queries/useReportQuery';
|
||||
import { Goal } from './Goal';
|
||||
|
||||
export function BoardGoal({
|
||||
@@ -18,7 +18,7 @@ export function BoardGoal({
|
||||
const {
|
||||
dateRange: { startDate, endDate },
|
||||
} = useDateRange();
|
||||
const { data, isLoading, error, isFetching } = useReportQuery(reportId || '');
|
||||
const { data, isLoading, error, isFetching } = useGoalDefinitionQuery(websiteId, reportId || '');
|
||||
|
||||
if (!reportId) {
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Column, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useMessages, useNavigation, useResultQuery } from '@/components/hooks';
|
||||
import { useGoalQuery, useMessages, useNavigation } from '@/components/hooks';
|
||||
import { File, User } from '@/components/icons';
|
||||
import { ReportEditButton } from '@/components/input/ReportEditButton';
|
||||
import { SavedDefinitionEditButton } from '@/components/input/SavedDefinitionEditButton';
|
||||
import { Lightning } from '@/components/svg';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
import { GoalEditForm } from './GoalEditForm';
|
||||
@@ -37,8 +37,9 @@ export function Goal({
|
||||
const { t, labels } = useMessages();
|
||||
const { pathname } = useNavigation();
|
||||
const isSharePage = pathname.includes('/share/');
|
||||
const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, {
|
||||
const { data, error, isLoading, isFetching } = useGoalQuery({
|
||||
websiteId,
|
||||
id,
|
||||
startDate,
|
||||
endDate,
|
||||
...parameters,
|
||||
@@ -57,7 +58,8 @@ export function Goal({
|
||||
</Column>
|
||||
{allowEdit && !isSharePage && (
|
||||
<Column>
|
||||
<ReportEditButton
|
||||
<SavedDefinitionEditButton
|
||||
websiteId={websiteId}
|
||||
id={id}
|
||||
name={name}
|
||||
type={type}
|
||||
@@ -66,7 +68,7 @@ export function Goal({
|
||||
minHeight="300px"
|
||||
>
|
||||
{({ close }) => <GoalEditForm id={id} websiteId={websiteId} onClose={close} />}
|
||||
</ReportEditButton>
|
||||
</SavedDefinitionEditButton>
|
||||
</Column>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Loading,
|
||||
TextField,
|
||||
} from '@umami/react-zen';
|
||||
import { useMessages, useMobile, useReportQuery, useUpdateQuery } from '@/components/hooks';
|
||||
import { useGoalDefinitionQuery, useMessages, useMobile, useUpdateQuery } from '@/components/hooks';
|
||||
import { ActionSelect } from '@/components/input/ActionSelect';
|
||||
import { LookupField } from '@/components/input/LookupField';
|
||||
|
||||
@@ -27,16 +27,17 @@ export function GoalEditForm({
|
||||
}) {
|
||||
const { t, labels } = useMessages();
|
||||
const { isMobile } = useMobile();
|
||||
const { data } = useReportQuery(id);
|
||||
const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);
|
||||
const { data } = useGoalDefinitionQuery(websiteId, id);
|
||||
const { mutateAsync, error, isPending, touch } = useUpdateQuery(
|
||||
`/websites/${websiteId}/goals${id ? `/${id}` : ''}`,
|
||||
);
|
||||
|
||||
const handleSubmit = async (formData: Record<string, any>) => {
|
||||
await mutateAsync(
|
||||
{ ...formData, type: 'goal', websiteId },
|
||||
{ name: formData.name, description: formData.description, parameters: formData.parameters },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
if (id) touch(`report:${id}`);
|
||||
touch('reports:goal');
|
||||
touch('websites:goals');
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
},
|
||||
@@ -72,7 +73,14 @@ export function GoalEditForm({
|
||||
</FormField>
|
||||
<FormField name="parameters.value" rules={{ required: t(labels.required) }}>
|
||||
{({ field }) => {
|
||||
return <LookupField websiteId={websiteId} type={type} allowCustomValue {...field} />;
|
||||
return (
|
||||
<LookupField
|
||||
websiteId={websiteId}
|
||||
type={type}
|
||||
allowCustomValue
|
||||
{...field}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FormField>
|
||||
</Column>
|
||||
@@ -86,7 +94,14 @@ export function GoalEditForm({
|
||||
<Column style={{ minWidth: 0 }}>
|
||||
<FormField name="parameters.value" rules={{ required: t(labels.required) }}>
|
||||
{({ field }) => {
|
||||
return <LookupField websiteId={websiteId} type={type} allowCustomValue {...field} />;
|
||||
return (
|
||||
<LookupField
|
||||
websiteId={websiteId}
|
||||
type={type}
|
||||
allowCustomValue
|
||||
{...field}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FormField>
|
||||
</Column>
|
||||
|
||||
@@ -4,12 +4,12 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { SectionHeader } from '@/components/common/SectionHeader';
|
||||
import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
|
||||
import { useDateRange, useGoalsQuery, useNavigation } from '@/components/hooks';
|
||||
import { Goal } from './Goal';
|
||||
import { GoalAddButton } from './GoalAddButton';
|
||||
|
||||
export function GoalsPage({ websiteId }: { websiteId: string }) {
|
||||
const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'goal' });
|
||||
const { data, isLoading, error } = useGoalsQuery({ websiteId });
|
||||
const {
|
||||
dateRange: { startDate, endDate },
|
||||
} = useDateRange();
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ControlledDialog } from '@/components/common/ControlledDialog';
|
||||
import { IconLabel } from '@/components/common/IconLabel';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useMobile, useResultQuery } from '@/components/hooks';
|
||||
import { useHeatmapQuery, useMobile } from '@/components/hooks';
|
||||
import { ListCheck } from '@/components/icons';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from '@/queries/sql';
|
||||
@@ -65,7 +65,7 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
|
||||
data: pagesData,
|
||||
error,
|
||||
isLoading,
|
||||
} = useResultQuery<HeatmapResult>('heatmap', {
|
||||
} = useHeatmapQuery({
|
||||
websiteId,
|
||||
mode,
|
||||
});
|
||||
@@ -74,8 +74,7 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
|
||||
data: detailData,
|
||||
isLoading: isDetailLoading,
|
||||
isFetching: isDetailFetching,
|
||||
} = useResultQuery<HeatmapResult>(
|
||||
'heatmap',
|
||||
} = useHeatmapQuery(
|
||||
{
|
||||
websiteId,
|
||||
urlPath: urlPath || undefined,
|
||||
@@ -134,7 +133,12 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
|
||||
{isPhone ? (
|
||||
<Column gap="4" minHeight="900px">
|
||||
<Column gap="2" className={styles.mobilePageSection}>
|
||||
<Row alignItems="center" justifyContent="space-between" gap className={styles.mobilePageHeader}>
|
||||
<Row
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap
|
||||
className={styles.mobilePageHeader}
|
||||
>
|
||||
<Text color="muted" className={styles.mobileSectionLabel}>
|
||||
Selected page
|
||||
</Text>
|
||||
@@ -1005,13 +1009,7 @@ function SnapshotPreview({
|
||||
return <IframeSnapshot snapshot={snapshot} onReady={onReady} />;
|
||||
}
|
||||
|
||||
function IframeSnapshot({
|
||||
snapshot,
|
||||
onReady,
|
||||
}: {
|
||||
snapshot: HeatmapSnapshot;
|
||||
onReady: () => void;
|
||||
}) {
|
||||
function IframeSnapshot({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onReady: () => void }) {
|
||||
const [available, setAvailable] = useState(true);
|
||||
const iframeUrl = snapshot.url;
|
||||
const frameHeight = getSnapshotFrameHeight(snapshot);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Column, cn, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/rea
|
||||
import { useMemo, useState } from 'react';
|
||||
import { firstBy } from 'thenby';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks';
|
||||
import { useEscapeKey, useJourneyQuery, useMessages } from '@/components/hooks';
|
||||
import { File } from '@/components/icons';
|
||||
import { Lightning } from '@/components/svg';
|
||||
import { objectToArray } from '@/lib/data';
|
||||
@@ -32,12 +32,11 @@ export function Journey({ websiteId, steps, startStep, endStep, view }: JourneyP
|
||||
const [selectedNode, setSelectedNode] = useState(null);
|
||||
const [activeNode, setActiveNode] = useState(null);
|
||||
const { t, labels } = useMessages();
|
||||
const { data, error, isLoading } = useResultQuery<any>('journey', {
|
||||
const { data, error, isLoading } = useJourneyQuery({
|
||||
websiteId,
|
||||
steps,
|
||||
startStep,
|
||||
endStep,
|
||||
view,
|
||||
eventType: EVENT_TYPES[view],
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,13 @@ import { BarChart } from '@/components/charts/BarChart';
|
||||
import { GridRow } from '@/components/common/GridRow';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useLocale, useMessages, useResultQuery } from '@/components/hooks';
|
||||
import {
|
||||
useLocale,
|
||||
useMessages,
|
||||
usePerformanceChartQuery,
|
||||
usePerformanceMetricsQuery,
|
||||
usePerformanceStatsQuery,
|
||||
} from '@/components/hooks';
|
||||
import { ListTable } from '@/components/metrics/ListTable';
|
||||
import { MetricLabel } from '@/components/metrics/MetricLabel';
|
||||
import { PerformanceCard } from '@/components/metrics/PerformanceCard';
|
||||
@@ -63,12 +69,10 @@ export function Performance({ websiteId, startDate, endDate, unit }: Performance
|
||||
const { t, labels } = useMessages();
|
||||
const { locale, dateLocale } = useLocale();
|
||||
|
||||
const { data, error, isLoading } = useResultQuery<any>('performance', {
|
||||
websiteId,
|
||||
startDate,
|
||||
endDate,
|
||||
metric: selectedMetric,
|
||||
});
|
||||
const queryParams = { websiteId, startDate, endDate, unit };
|
||||
const stats = usePerformanceStatsQuery(queryParams);
|
||||
const chart = usePerformanceChartQuery({ ...queryParams, metric: selectedMetric });
|
||||
const data = chart.data;
|
||||
|
||||
const chartData: any = useMemo(() => {
|
||||
if (!data?.chart) return { datasets: [] };
|
||||
@@ -158,147 +162,137 @@ export function Performance({ websiteId, startDate, endDate, unit }: Performance
|
||||
))}
|
||||
</Select>
|
||||
</Grid>
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||
{data && (
|
||||
<Column gap>
|
||||
<Grid columns={{ base: '1fr 1fr', lg: 'repeat(5, 1fr)' }} gap>
|
||||
{METRICS.map(metric => (
|
||||
<PerformanceCard
|
||||
key={metric}
|
||||
metric={metric}
|
||||
value={Number(data.summary?.[metric]?.[selectedPercentile] || 0)}
|
||||
label={t(labels[metric]) || metric.toUpperCase()}
|
||||
formatValue={(n: number) => formatMetricValue(metric, n)}
|
||||
onClick={() => setSelectedMetric(metric)}
|
||||
selected={selectedMetric === metric}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
<Panel>
|
||||
<Column gap="4" padding="4">
|
||||
<Row justifyContent="space-between" alignItems="center">
|
||||
<Text weight="bold">{METRIC_LABELS[selectedMetric]}</Text>
|
||||
<Row gap="4">
|
||||
<Text size="sm" className={styles.sampleCount}>
|
||||
{t(labels.sampleSize)}: {formatLongNumber(data.summary?.count || 0)}
|
||||
</Text>
|
||||
</Row>
|
||||
<Column gap>
|
||||
<LoadingPanel data={stats.data} isLoading={stats.isLoading} error={stats.error}>
|
||||
<Grid columns={{ base: '1fr 1fr', lg: 'repeat(5, 1fr)' }} gap>
|
||||
{METRICS.map(metric => (
|
||||
<PerformanceCard
|
||||
key={metric}
|
||||
metric={metric}
|
||||
value={Number(stats.data?.[metric]?.[selectedPercentile] || 0)}
|
||||
label={t(labels[metric]) || metric.toUpperCase()}
|
||||
formatValue={(n: number) => formatMetricValue(metric, n)}
|
||||
onClick={() => setSelectedMetric(metric)}
|
||||
selected={selectedMetric === metric}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</LoadingPanel>
|
||||
<LoadingPanel data={chart.data} isLoading={chart.isLoading} error={chart.error}>
|
||||
<Panel>
|
||||
<Column gap="4" padding="4">
|
||||
<Row justifyContent="space-between" alignItems="center">
|
||||
<Text weight="bold">{METRIC_LABELS[selectedMetric]}</Text>
|
||||
<Row gap="4">
|
||||
<Text size="sm" className={styles.sampleCount}>
|
||||
{t(labels.sampleSize)}: {formatLongNumber(stats.data?.count || 0)}
|
||||
</Text>
|
||||
</Row>
|
||||
<BarChart
|
||||
chartData={chartData}
|
||||
minDate={startDate}
|
||||
maxDate={endDate}
|
||||
unit={unit}
|
||||
renderXLabel={renderXLabel}
|
||||
renderYLabel={(label: string) => {
|
||||
const val = Number(label);
|
||||
if (selectedMetric === 'cls') return val.toFixed(2);
|
||||
if (val >= 1000) return `${(val / 1000).toFixed(2)} s`;
|
||||
return `${Math.round(val)} ms`;
|
||||
}}
|
||||
height="400px"
|
||||
</Row>
|
||||
<BarChart
|
||||
chartData={chartData}
|
||||
minDate={startDate}
|
||||
maxDate={endDate}
|
||||
unit={unit}
|
||||
renderXLabel={renderXLabel}
|
||||
renderYLabel={(label: string) => {
|
||||
const val = Number(label);
|
||||
if (selectedMetric === 'cls') return val.toFixed(2);
|
||||
if (val >= 1000) return `${(val / 1000).toFixed(2)} s`;
|
||||
return `${Math.round(val)} ms`;
|
||||
}}
|
||||
height="400px"
|
||||
/>
|
||||
</Column>
|
||||
</Panel>
|
||||
</LoadingPanel>
|
||||
<GridRow layout="two">
|
||||
<Panel>
|
||||
<Tabs>
|
||||
<Heading size="2xl">{t(labels.pages)}</Heading>
|
||||
<TabList>
|
||||
<Tab id="path">{t(labels.path)}</Tab>
|
||||
<Tab id="title">{t(labels.pageTitle)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="path">
|
||||
<PerformanceMetricsTable
|
||||
params={{ ...queryParams, metric: selectedMetric, type: 'path' }}
|
||||
percentile={selectedPercentile}
|
||||
metricLabel={metricLabel}
|
||||
formatCount={formatListCount}
|
||||
renderLabel={({ label }: { label: string }) => <Text>{label}</Text>}
|
||||
/>
|
||||
</Column>
|
||||
</Panel>
|
||||
<GridRow layout="two">
|
||||
<Panel>
|
||||
<Tabs>
|
||||
<Heading size="2xl">{t(labels.pages)}</Heading>
|
||||
<TabList>
|
||||
<Tab id="path">{t(labels.path)}</Tab>
|
||||
<Tab id="title">{t(labels.pageTitle)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="path">
|
||||
<ListTable
|
||||
metric={metricLabel}
|
||||
showPercentage={false}
|
||||
formatCount={formatListCount}
|
||||
data={data.pages
|
||||
?.filter(
|
||||
({ p50, p75, p95 }: any) =>
|
||||
Number({ p50, p75, p95 }[selectedPercentile]) > 0,
|
||||
)
|
||||
.slice(0, 20)
|
||||
.map(({ name, p50, p75, p95 }: any) => ({
|
||||
label: name,
|
||||
count: Number({ p50, p75, p95 }[selectedPercentile]),
|
||||
percent: 0,
|
||||
}))}
|
||||
renderLabel={({ label }: { label: string }) => <Text>{label}</Text>}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="title">
|
||||
<ListTable
|
||||
metric={metricLabel}
|
||||
showPercentage={false}
|
||||
formatCount={formatListCount}
|
||||
data={data.pageTitles
|
||||
?.filter(
|
||||
({ p50, p75, p95 }: any) =>
|
||||
Number({ p50, p75, p95 }[selectedPercentile]) > 0,
|
||||
)
|
||||
.slice(0, 20)
|
||||
.map(({ name, p50, p75, p95 }: any) => ({
|
||||
label: name,
|
||||
count: Number({ p50, p75, p95 }[selectedPercentile]),
|
||||
percent: 0,
|
||||
}))}
|
||||
renderLabel={(row: any) => <MetricLabel type="title" data={row} />}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
<Panel>
|
||||
<Tabs>
|
||||
<Heading size="2xl">{t(labels.environment)}</Heading>
|
||||
<TabList>
|
||||
<Tab id="device">{t(labels.device)}</Tab>
|
||||
<Tab id="browser">{t(labels.browser)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="device">
|
||||
<ListTable
|
||||
metric={metricLabel}
|
||||
showPercentage={false}
|
||||
formatCount={formatListCount}
|
||||
data={data.devices
|
||||
?.filter(
|
||||
({ p50, p75, p95 }: any) =>
|
||||
Number({ p50, p75, p95 }[selectedPercentile]) > 0,
|
||||
)
|
||||
.slice(0, 20)
|
||||
.map(({ name, p50, p75, p95 }: any) => ({
|
||||
label: name,
|
||||
count: Number({ p50, p75, p95 }[selectedPercentile]),
|
||||
percent: 0,
|
||||
}))}
|
||||
renderLabel={(row: any) => <MetricLabel type="device" data={row} />}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="browser">
|
||||
<ListTable
|
||||
metric={metricLabel}
|
||||
showPercentage={false}
|
||||
formatCount={formatListCount}
|
||||
data={data.browsers
|
||||
?.filter(
|
||||
({ p50, p75, p95 }: any) =>
|
||||
Number({ p50, p75, p95 }[selectedPercentile]) > 0,
|
||||
)
|
||||
.slice(0, 20)
|
||||
.map(({ name, p50, p75, p95 }: any) => ({
|
||||
label: name,
|
||||
count: Number({ p50, p75, p95 }[selectedPercentile]),
|
||||
percent: 0,
|
||||
}))}
|
||||
renderLabel={(row: any) => <MetricLabel type="browser" data={row} />}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
</GridRow>
|
||||
</Column>
|
||||
)}
|
||||
</LoadingPanel>
|
||||
</TabPanel>
|
||||
<TabPanel id="title">
|
||||
<PerformanceMetricsTable
|
||||
params={{ ...queryParams, metric: selectedMetric, type: 'title' }}
|
||||
percentile={selectedPercentile}
|
||||
metricLabel={metricLabel}
|
||||
formatCount={formatListCount}
|
||||
renderLabel={(row: any) => <MetricLabel type="title" data={row} />}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
<Panel>
|
||||
<Tabs>
|
||||
<Heading size="2xl">{t(labels.environment)}</Heading>
|
||||
<TabList>
|
||||
<Tab id="device">{t(labels.device)}</Tab>
|
||||
<Tab id="browser">{t(labels.browser)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="device">
|
||||
<PerformanceMetricsTable
|
||||
params={{ ...queryParams, metric: selectedMetric, type: 'device' }}
|
||||
percentile={selectedPercentile}
|
||||
metricLabel={metricLabel}
|
||||
formatCount={formatListCount}
|
||||
renderLabel={(row: any) => <MetricLabel type="device" data={row} />}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="browser">
|
||||
<PerformanceMetricsTable
|
||||
params={{ ...queryParams, metric: selectedMetric, type: 'browser' }}
|
||||
percentile={selectedPercentile}
|
||||
metricLabel={metricLabel}
|
||||
formatCount={formatListCount}
|
||||
renderLabel={(row: any) => <MetricLabel type="browser" data={row} />}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
</GridRow>
|
||||
</Column>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
function PerformanceMetricsTable({
|
||||
params,
|
||||
percentile,
|
||||
metricLabel,
|
||||
formatCount,
|
||||
renderLabel,
|
||||
}: {
|
||||
params: Parameters<typeof usePerformanceMetricsQuery>[0];
|
||||
percentile: 'p50' | 'p75' | 'p95';
|
||||
metricLabel: string;
|
||||
formatCount: (value: number) => string;
|
||||
renderLabel: (row: any) => React.ReactNode;
|
||||
}) {
|
||||
const { data, isLoading, error } = usePerformanceMetricsQuery(params);
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error}>
|
||||
<ListTable
|
||||
metric={metricLabel}
|
||||
showPercentage={false}
|
||||
formatCount={formatCount}
|
||||
data={(data ?? [])
|
||||
.filter(row => Number(row[percentile]) > 0)
|
||||
.slice(0, 20)
|
||||
.map(row => ({ label: row.name, count: Number(row[percentile]), percent: 0 }))}
|
||||
renderLabel={renderLabel}
|
||||
/>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { differenceInCalendarDays } from 'date-fns';
|
||||
import type { ReactNode } from 'react';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useLocale, useMessages, useResultQuery } from '@/components/hooks';
|
||||
import { useLocale, useMessages, useRetentionQuery } from '@/components/hooks';
|
||||
import { Users } from '@/components/icons';
|
||||
import { formatDate } from '@/lib/date';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
@@ -20,7 +20,7 @@ export interface RetentionProps {
|
||||
export function Retention({ websiteId, days = DAYS, startDate, endDate }: RetentionProps) {
|
||||
const { t, labels } = useMessages();
|
||||
const { locale } = useLocale();
|
||||
const { data, error, isLoading } = useResultQuery('retention', {
|
||||
const { data, error, isLoading } = useRetentionQuery({
|
||||
websiteId,
|
||||
startDate,
|
||||
endDate,
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { useDateRange, useMessages, useUTMMetricsQuery } from '@/components/hooks';
|
||||
import { ListTable } from '@/components/metrics/ListTable';
|
||||
import { CHART_COLORS } from '@/lib/constants';
|
||||
|
||||
@@ -23,14 +23,15 @@ export function BoardUTM({
|
||||
dateRange: { startDate, endDate },
|
||||
} = useDateRange();
|
||||
const { t, labels } = useMessages();
|
||||
const { data, error, isLoading } = useResultQuery<any>('utm', {
|
||||
const { data, error, isLoading } = useUTMMetricsQuery({
|
||||
type: param,
|
||||
websiteId,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
const itemLimit = Number(limit) || 10;
|
||||
const items = (data?.[param] ?? []).slice(0, itemLimit);
|
||||
const items = (data ?? []).slice(0, itemLimit);
|
||||
const total = items.reduce((sum, { views }) => {
|
||||
return +sum + +views;
|
||||
}, 0);
|
||||
@@ -78,9 +79,7 @@ export function BoardUTM({
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isStacked
|
||||
? 'minmax(0, 1fr)'
|
||||
: 'minmax(0, 1fr) minmax(0, 1fr)',
|
||||
gridTemplateColumns: isStacked ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) minmax(0, 1fr)',
|
||||
gap: 24,
|
||||
alignItems: 'start',
|
||||
}}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Column, Grid, Heading, Text } from '@umami/react-zen';
|
||||
import { PieChart } from '@/components/charts/PieChart';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { useMessages, useResultQuery } from '@/components/hooks';
|
||||
import { useMessages, useUTMMetricsQuery } from '@/components/hooks';
|
||||
import { ListTable } from '@/components/metrics/ListTable';
|
||||
import { CHART_COLORS, UTM_PARAMS } from '@/lib/constants';
|
||||
|
||||
@@ -12,60 +12,59 @@ export interface UTMProps {
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export function UTM({ websiteId, startDate, endDate }: UTMProps) {
|
||||
export function UTM(props: UTMProps) {
|
||||
return (
|
||||
<Column gap>
|
||||
{UTM_PARAMS.map(param => (
|
||||
<UTMDimension key={param} {...props} param={param} />
|
||||
))}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
function UTMDimension({ websiteId, startDate, endDate, param }: UTMProps & { param: string }) {
|
||||
const { t, labels } = useMessages();
|
||||
const { data, error, isLoading } = useResultQuery<any>('utm', {
|
||||
websiteId,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
const {
|
||||
data: items = [],
|
||||
error,
|
||||
isLoading,
|
||||
} = useUTMMetricsQuery({ websiteId, startDate, endDate, type: param });
|
||||
const chartData = {
|
||||
labels: items.map(({ utm }) => utm),
|
||||
datasets: [
|
||||
{
|
||||
data: items.map(({ views }) => views),
|
||||
backgroundColor: CHART_COLORS,
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
const total = items.reduce((sum, { views }) => {
|
||||
return +sum + +views;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="300px">
|
||||
{data && (
|
||||
<Column gap>
|
||||
{UTM_PARAMS.map(param => {
|
||||
const items = data?.[param];
|
||||
|
||||
const chartData = {
|
||||
labels: items.map(({ utm }) => utm),
|
||||
datasets: [
|
||||
{
|
||||
data: items.map(({ views }) => views),
|
||||
backgroundColor: CHART_COLORS,
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
const total = items.reduce((sum, { views }) => {
|
||||
return +sum + +views;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<Panel key={param}>
|
||||
<Grid columns={{ base: '1fr', md: '1fr 1fr' }} gap="6">
|
||||
<Column>
|
||||
<Heading>
|
||||
<Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>
|
||||
</Heading>
|
||||
<ListTable
|
||||
metric={t(labels.views)}
|
||||
data={items.map(({ utm, views }) => ({
|
||||
label: utm,
|
||||
count: views,
|
||||
percent: (views / total) * 100,
|
||||
}))}
|
||||
/>
|
||||
</Column>
|
||||
<Column>
|
||||
<PieChart type="doughnut" chartData={chartData} />
|
||||
</Column>
|
||||
</Grid>
|
||||
</Panel>
|
||||
);
|
||||
})}
|
||||
</Column>
|
||||
)}
|
||||
<LoadingPanel data={items} isLoading={isLoading} error={error} minHeight="300px">
|
||||
<Panel>
|
||||
<Grid columns={{ base: '1fr', md: '1fr 1fr' }} gap="6">
|
||||
<Column>
|
||||
<Heading>
|
||||
<Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>
|
||||
</Heading>
|
||||
<ListTable
|
||||
metric={t(labels.views)}
|
||||
data={items.map(({ utm, views }) => ({
|
||||
label: utm,
|
||||
count: views,
|
||||
percent: (views / total) * 100,
|
||||
}))}
|
||||
/>
|
||||
</Column>
|
||||
<Column>
|
||||
<PieChart type="doughnut" chartData={chartData} />
|
||||
</Column>
|
||||
</Grid>
|
||||
</Panel>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
@@ -13,5 +13,5 @@ export async function GET(
|
||||
const { websiteId } = await params;
|
||||
if (!(await canViewWebsiteSection(auth, websiteId, 'utm'))) return unauthorized();
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
return json(await getUTM(websiteId, { ...filters, column: query.type }, filters));
|
||||
return json(await getUTM(websiteId, { startDate: filters.startDate, endDate: filters.endDate, column: query.type }, filters));
|
||||
}
|
||||
@@ -12,22 +12,28 @@ export * from './context/useWebsite';
|
||||
// Query hooks
|
||||
export * from './queries/useActiveUsersQuery';
|
||||
export * from './queries/useApiKeysQuery';
|
||||
export * from './queries/useAttributionQuery';
|
||||
export * from './queries/useBoardQuery';
|
||||
export * from './queries/useBoardSharesQuery';
|
||||
export * from './queries/useBoardsQuery';
|
||||
export * from './queries/useBreakdownQuery';
|
||||
export * from './queries/useDashboardQuery';
|
||||
export * from './queries/useDateRangeQuery';
|
||||
export * from './queries/useDeleteQuery';
|
||||
export * from './queries/useEventDataPivotQuery';
|
||||
export * from './queries/useEventDataPropertiesQuery';
|
||||
export * from './queries/useEventDataQuery';
|
||||
export * from './queries/useFunnelQuery';
|
||||
export * from './queries/useGoalQuery';
|
||||
export * from './queries/useHeatmapQuery';
|
||||
export * from './queries/useJourneyQuery';
|
||||
export * from './queries/useLinkQuery';
|
||||
export * from './queries/useLinkSharesQuery';
|
||||
export * from './queries/useLinksQuery';
|
||||
export * from './queries/useLoginQuery';
|
||||
export * from './queries/useTwoFactorStatusQuery';
|
||||
export * from './queries/useTwoFactorUserStatus';
|
||||
export * from './queries/useTwoFactorVerifyMutation';
|
||||
export * from './queries/usePerformanceChartQuery';
|
||||
export * from './queries/usePerformanceMetricsQuery';
|
||||
export * from './queries/usePerformanceStatsQuery';
|
||||
export * from './queries/usePixelQuery';
|
||||
export * from './queries/usePixelSharesQuery';
|
||||
export * from './queries/usePixelsQuery';
|
||||
@@ -42,13 +48,12 @@ export * from './queries/useRealtimeQuery';
|
||||
export * from './queries/useReplayQuery';
|
||||
export * from './queries/useReplaySavedQuery';
|
||||
export * from './queries/useReplaysQuery';
|
||||
export * from './queries/useReportQuery';
|
||||
export * from './queries/useReportsQuery';
|
||||
export * from './queries/useResultQuery';
|
||||
export * from './queries/useRetentionQuery';
|
||||
export * from './queries/useRevenueChartQuery';
|
||||
export * from './queries/useRevenueMetricsQuery';
|
||||
export * from './queries/useRevenueSessionsQuery';
|
||||
export * from './queries/useRevenueStatsQuery';
|
||||
export * from './queries/useSavedDefinitionsQuery';
|
||||
export * from './queries/useSavedReplaysQuery';
|
||||
export * from './queries/useSessionActivityQuery';
|
||||
export * from './queries/useSessionDataActivityStatsQuery';
|
||||
@@ -62,21 +67,25 @@ export * from './queries/useTeamMembersQuery';
|
||||
export * from './queries/useTeamQuery';
|
||||
export * from './queries/useTeamsQuery';
|
||||
export * from './queries/useTeamWebsitesQuery';
|
||||
export * from './queries/useTwoFactorStatusQuery';
|
||||
export * from './queries/useTwoFactorUserStatus';
|
||||
export * from './queries/useTwoFactorVerifyMutation';
|
||||
export * from './queries/useUpdateQuery';
|
||||
export * from './queries/useUserQuery';
|
||||
export * from './queries/useUsersQuery';
|
||||
export * from './queries/useUserTeamsQuery';
|
||||
export * from './queries/useUserWebsitesQuery';
|
||||
export * from './queries/useUTMMetricsQuery';
|
||||
export * from './queries/useWebsiteAnnotationsQuery';
|
||||
export * from './queries/useWebsiteCohortQuery';
|
||||
export * from './queries/useWebsiteCohortsQuery';
|
||||
export * from './queries/useWebsiteEventsQuery';
|
||||
export * from './queries/useWebsiteEventsSeriesQuery';
|
||||
export * from './queries/useWebsiteExpandedMetricsQuery';
|
||||
export * from './queries/useWebsiteListChartsQuery';
|
||||
export * from './queries/useWebsiteMetricsQuery';
|
||||
export * from './queries/useWebsitePageviewsQuery';
|
||||
export * from './queries/useWebsiteListChartsQuery';
|
||||
export * from './queries/useWebsiteQuery';
|
||||
export * from './queries/useWebsiteAnnotationsQuery';
|
||||
export * from './queries/useWebsiteSegmentQuery';
|
||||
export * from './queries/useWebsiteSegmentsQuery';
|
||||
export * from './queries/useWebsiteSessionQuery';
|
||||
@@ -87,7 +96,6 @@ export * from './queries/useWebsiteStatsQuery';
|
||||
export * from './queries/useWebsitesQuery';
|
||||
export * from './queries/useWebsiteValuesQuery';
|
||||
export * from './queries/useWeeklyTrafficQuery';
|
||||
|
||||
// Regular hooks
|
||||
export * from './useApi';
|
||||
export * from './useConfig';
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { useFunnelQuery } from './useFunnelQuery';
|
||||
import { usePerformanceStatsQuery } from './usePerformanceStatsQuery';
|
||||
import { useUTMMetricsQuery } from './useUTMMetricsQuery';
|
||||
const mocks = vi.hoisted(() => ({ get: vi.fn(), query: vi.fn((options: any) => options) }));
|
||||
vi.mock('../useApi', () => ({ useApi: () => ({ get: mocks.get, useQuery: mocks.query }) }));
|
||||
vi.mock('../useDateParameters', () => ({
|
||||
useDateParameters: () => ({ startAt: 1000, endAt: 2000, timezone: 'UTC', unit: 'day' }),
|
||||
}));
|
||||
vi.mock('../useFilterParameters', () => ({
|
||||
useFilterParameters: () => ({ browser1: 'eq.Chrome' }),
|
||||
}));
|
||||
vi.mock('../useModified', () => ({ useModified: () => ({ modified: 1 }) }));
|
||||
|
||||
test('saved funnels use ID stats with global dates, timezone, and filters', async () => {
|
||||
renderHook(() => useFunnelQuery({ websiteId: 'w', id: 'saved', steps: [] }));
|
||||
const options = mocks.query.mock.lastCall[0];
|
||||
await options.queryFn();
|
||||
expect(mocks.get.mock.lastCall).toEqual([
|
||||
'/websites/w/funnels/saved/stats',
|
||||
{ startAt: 1000, endAt: 2000, timezone: 'UTC', unit: 'day', browser1: 'eq.Chrome' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('UTM widgets fetch only their selected dimension', async () => {
|
||||
renderHook(() => useUTMMetricsQuery({ websiteId: 'w', type: 'utm_source' }));
|
||||
await mocks.query.mock.lastCall[0].queryFn();
|
||||
expect(mocks.get.mock.lastCall[0]).toBe('/websites/w/utm/metrics');
|
||||
expect(mocks.get.mock.lastCall[1].type).toBe('utm_source');
|
||||
});
|
||||
|
||||
test('performance stats cache does not depend on a selected chart metric', () => {
|
||||
renderHook(() => usePerformanceStatsQuery({ websiteId: 'w' }));
|
||||
expect(mocks.query.mock.lastCall[0].queryKey).toEqual([
|
||||
'websites:performance/stats',
|
||||
{
|
||||
websiteId: 'w',
|
||||
startAt: 1000,
|
||||
endAt: 2000,
|
||||
timezone: 'UTC',
|
||||
unit: 'day',
|
||||
browser1: 'eq.Chrome',
|
||||
modified: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { type AnalyticsParameters, serializeAnalyticsQuery } from '@/lib/analytics-query';
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useDateParameters } from '../useDateParameters';
|
||||
import { useFilterParameters } from '../useFilterParameters';
|
||||
import { useModified } from '../useModified';
|
||||
|
||||
export function useAnalyticsQuery<T>(
|
||||
path: string,
|
||||
params: AnalyticsParameters & Record<string, any>,
|
||||
options?: ReactQueryOptions<T>,
|
||||
) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { startAt, endAt, timezone, unit } = useDateParameters();
|
||||
const filters = useFilterParameters({ includePagination: false });
|
||||
const { modified } = useModified(`websites:${path.split('/')[0]}`);
|
||||
const query = serializeAnalyticsQuery({
|
||||
startAt,
|
||||
endAt,
|
||||
timezone,
|
||||
unit,
|
||||
...filters,
|
||||
...Object.fromEntries(Object.entries(params).filter(([, value]) => value !== undefined)),
|
||||
});
|
||||
return useQuery<T>({
|
||||
queryKey: [`websites:${path}`, { websiteId: params.websiteId, ...query, modified }],
|
||||
queryFn: () => get(`/websites/${params.websiteId}/${path}`, query),
|
||||
enabled: !!params.websiteId,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { AnalyticsParameters } from '@/lib/analytics-query';
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import type {
|
||||
AttributionParameters,
|
||||
AttributionResult,
|
||||
} from '@/queries/sql/attribution/getAttribution';
|
||||
import { useAnalyticsQuery } from './useAnalyticsQuery';
|
||||
|
||||
export function useAttributionQuery(
|
||||
params: AnalyticsParameters & Partial<Omit<AttributionParameters, 'startDate' | 'endDate'>>,
|
||||
options?: ReactQueryOptions<AttributionResult>,
|
||||
) {
|
||||
return useAnalyticsQuery<AttributionResult>('attribution', params, options);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AnalyticsParameters } from '@/lib/analytics-query';
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import type { BreakdownData, BreakdownParameters } from '@/queries/sql/breakdown/getBreakdown';
|
||||
import { useAnalyticsQuery } from './useAnalyticsQuery';
|
||||
|
||||
export function useBreakdownQuery(
|
||||
params: AnalyticsParameters & Partial<Omit<BreakdownParameters, 'startDate' | 'endDate'>>,
|
||||
options?: ReactQueryOptions<BreakdownData[]>,
|
||||
) {
|
||||
return useAnalyticsQuery<BreakdownData[]>('breakdown', params, options);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { AnalyticsParameters } from '@/lib/analytics-query';
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import type { FunnelParameters, FunnelResult } from '@/queries/sql/funnels/getFunnel';
|
||||
import { useAnalyticsQuery } from './useAnalyticsQuery';
|
||||
|
||||
export function useFunnelQuery(
|
||||
params: AnalyticsParameters &
|
||||
Partial<Omit<FunnelParameters, 'startDate' | 'endDate'>> & { id?: string },
|
||||
options?: ReactQueryOptions<FunnelResult[]>,
|
||||
) {
|
||||
return useAnalyticsQuery<FunnelResult[]>(
|
||||
params.id ? `funnels/${params.id}/stats` : 'funnels/stats',
|
||||
params.id
|
||||
? {
|
||||
websiteId: params.websiteId,
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
unit: params.unit,
|
||||
timezone: params.timezone,
|
||||
}
|
||||
: params,
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { AnalyticsParameters } from '@/lib/analytics-query';
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import type { GoalParameters, GoalResult } from '@/queries/sql/goals/getGoal';
|
||||
import { useAnalyticsQuery } from './useAnalyticsQuery';
|
||||
|
||||
export function useGoalQuery(
|
||||
params: AnalyticsParameters &
|
||||
Partial<Omit<GoalParameters, 'startDate' | 'endDate'>> & { id?: string },
|
||||
options?: ReactQueryOptions<GoalResult>,
|
||||
) {
|
||||
return useAnalyticsQuery<GoalResult>(
|
||||
params.id ? `goals/${params.id}/stats` : 'goals/stats',
|
||||
params.id
|
||||
? {
|
||||
websiteId: params.websiteId,
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
unit: params.unit,
|
||||
timezone: params.timezone,
|
||||
}
|
||||
: params,
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AnalyticsParameters } from '@/lib/analytics-query';
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import type { HeatmapParameters, HeatmapResult } from '@/queries/sql/heatmap/getHeatmap';
|
||||
import { useAnalyticsQuery } from './useAnalyticsQuery';
|
||||
|
||||
export function useHeatmapQuery(
|
||||
params: AnalyticsParameters & Partial<Omit<HeatmapParameters, 'startDate' | 'endDate'>>,
|
||||
options?: ReactQueryOptions<HeatmapResult>,
|
||||
) {
|
||||
return useAnalyticsQuery<HeatmapResult>('heatmaps', params, options);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AnalyticsParameters } from '@/lib/analytics-query';
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import type { JourneyParameters, JourneyResult } from '@/queries/sql/journeys/getJourney';
|
||||
import { useAnalyticsQuery } from './useAnalyticsQuery';
|
||||
|
||||
export function useJourneyQuery(
|
||||
params: AnalyticsParameters & Partial<Omit<JourneyParameters, 'startDate' | 'endDate'>> & { eventType?: number },
|
||||
options?: ReactQueryOptions<JourneyResult[]>,
|
||||
) {
|
||||
return useAnalyticsQuery<JourneyResult[]>('journeys', params, options);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AnalyticsParameters } from '@/lib/analytics-query';
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import type { PerformanceResult } from '@/queries/sql/performance/getPerformance';
|
||||
import { useAnalyticsQuery } from './useAnalyticsQuery';
|
||||
|
||||
export function usePerformanceChartQuery(
|
||||
params: AnalyticsParameters & { metric: string },
|
||||
options?: ReactQueryOptions<Pick<PerformanceResult, 'chart'>>,
|
||||
) {
|
||||
return useAnalyticsQuery<Pick<PerformanceResult, 'chart'>>('performance/chart', params, options);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { AnalyticsParameters } from '@/lib/analytics-query';
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import type { PerformanceMetricsData } from '@/queries/sql/performance/getPerformanceMetrics';
|
||||
import { useAnalyticsQuery } from './useAnalyticsQuery';
|
||||
|
||||
export function usePerformanceMetricsQuery(
|
||||
params: AnalyticsParameters & { metric: string } & {
|
||||
type: 'path' | 'title' | 'device' | 'browser';
|
||||
},
|
||||
options?: ReactQueryOptions<PerformanceMetricsData[]>,
|
||||
) {
|
||||
return useAnalyticsQuery<PerformanceMetricsData[]>('performance/metrics', params, options);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AnalyticsParameters } from '@/lib/analytics-query';
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import type { PerformanceResult } from '@/queries/sql/performance/getPerformance';
|
||||
import { useAnalyticsQuery } from './useAnalyticsQuery';
|
||||
|
||||
export function usePerformanceStatsQuery(
|
||||
params: AnalyticsParameters,
|
||||
options?: ReactQueryOptions<PerformanceResult['summary']>,
|
||||
) {
|
||||
return useAnalyticsQuery<PerformanceResult['summary']>('performance/stats', params, options);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useApi } from '../useApi';
|
||||
import { useModified } from '../useModified';
|
||||
|
||||
export function useReportQuery(reportId: string) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { modified } = useModified(`report:${reportId}`);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['report', { reportId, modified }],
|
||||
queryFn: () => {
|
||||
return get(`/reports/${reportId}`);
|
||||
},
|
||||
enabled: !!reportId,
|
||||
});
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useModified } from '../useModified';
|
||||
import { usePagedQuery } from '../usePagedQuery';
|
||||
|
||||
export function useReportsQuery(
|
||||
{ websiteId, type }: { websiteId: string; type?: string },
|
||||
options?: ReactQueryOptions,
|
||||
) {
|
||||
const { modified } = useModified(`reports:${type}`);
|
||||
const { get } = useApi();
|
||||
|
||||
return usePagedQuery({
|
||||
queryKey: ['reports', { websiteId, type, modified }],
|
||||
queryFn: async () => get('/reports', { websiteId, type }),
|
||||
enabled: !!websiteId && !!type,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useDateParameters } from '@/components/hooks/useDateParameters';
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useFilterParameters } from '../useFilterParameters';
|
||||
|
||||
export function useResultQuery<T = any>(
|
||||
type: string,
|
||||
params?: Record<string, any>,
|
||||
options?: ReactQueryOptions<T>,
|
||||
) {
|
||||
const { websiteId, ...parameters } = params;
|
||||
const { post, useQuery } = useApi();
|
||||
const { startDate, endDate, timezone, unit } = useDateParameters();
|
||||
const filters = useFilterParameters({ includePagination: false });
|
||||
|
||||
return useQuery<T>({
|
||||
queryKey: [
|
||||
'reports',
|
||||
{
|
||||
type,
|
||||
websiteId,
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
unit,
|
||||
...params,
|
||||
...filters,
|
||||
},
|
||||
],
|
||||
queryFn: () =>
|
||||
post(`/reports/${type}`, {
|
||||
websiteId,
|
||||
type,
|
||||
filters,
|
||||
parameters: {
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
unit,
|
||||
...parameters,
|
||||
},
|
||||
}),
|
||||
enabled: !!type,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AnalyticsParameters } from '@/lib/analytics-query';
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import type { RetentionParameters, RetentionResult } from '@/queries/sql/retention/getRetention';
|
||||
import { useAnalyticsQuery } from './useAnalyticsQuery';
|
||||
|
||||
export function useRetentionQuery(
|
||||
params: AnalyticsParameters & Partial<Omit<RetentionParameters, 'startDate' | 'endDate'>>,
|
||||
options?: ReactQueryOptions<RetentionResult[]>,
|
||||
) {
|
||||
return useAnalyticsQuery<RetentionResult[]>('retention', params, options);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Report } from '@/generated/prisma/client';
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { useApi } from '../useApi';
|
||||
import { useModified } from '../useModified';
|
||||
import { usePagedQuery } from '../usePagedQuery';
|
||||
|
||||
function useDefinitionsQuery(
|
||||
feature: 'funnels' | 'goals',
|
||||
websiteId: string,
|
||||
options?: ReactQueryOptions,
|
||||
) {
|
||||
const { modified } = useModified(`websites:${feature}`);
|
||||
const { get } = useApi();
|
||||
return usePagedQuery({
|
||||
queryKey: [`websites:${feature}`, { websiteId, modified }],
|
||||
queryFn: params => get(`/websites/${websiteId}/${feature}`, params),
|
||||
enabled: !!websiteId,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
function useDefinitionQuery(feature: 'funnels' | 'goals', websiteId: string, id: string) {
|
||||
const { modified } = useModified(`websites:${feature}`);
|
||||
const { get, useQuery } = useApi();
|
||||
return useQuery<Omit<Report, 'parameters'> & { parameters: any }>({
|
||||
queryKey: [`websites:${feature}:definition`, { websiteId, id, modified }],
|
||||
queryFn: () => get(`/websites/${websiteId}/${feature}/${id}`),
|
||||
enabled: !!websiteId && !!id,
|
||||
});
|
||||
}
|
||||
export function useFunnelsQuery({ websiteId }: { websiteId: string }, options?: ReactQueryOptions) {
|
||||
return useDefinitionsQuery('funnels', websiteId, options);
|
||||
}
|
||||
export function useGoalsQuery({ websiteId }: { websiteId: string }, options?: ReactQueryOptions) {
|
||||
return useDefinitionsQuery('goals', websiteId, options);
|
||||
}
|
||||
export function useFunnelDefinitionQuery(websiteId: string, id: string) {
|
||||
return useDefinitionQuery('funnels', websiteId, id);
|
||||
}
|
||||
export function useGoalDefinitionQuery(websiteId: string, id: string) {
|
||||
return useDefinitionQuery('goals', websiteId, id);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { AnalyticsParameters } from '@/lib/analytics-query';
|
||||
import type { ReactQueryOptions } from '@/lib/types';
|
||||
import { useAnalyticsQuery } from './useAnalyticsQuery';
|
||||
export type UTMMetricsData = { utm: string; views: number }[];
|
||||
export function useUTMMetricsQuery(
|
||||
params: AnalyticsParameters & { type: string },
|
||||
options?: ReactQueryOptions<UTMMetricsData>,
|
||||
) {
|
||||
return useAnalyticsQuery<UTMMetricsData>('utm/metrics', params, options);
|
||||
}
|
||||
+5
-3
@@ -17,8 +17,9 @@ import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery';
|
||||
import { Edit, MoreHorizontal, Trash } from '@/components/icons';
|
||||
import { DialogButton } from './DialogButton';
|
||||
|
||||
export function ReportEditButton({
|
||||
export function SavedDefinitionEditButton({
|
||||
id,
|
||||
websiteId,
|
||||
name,
|
||||
type,
|
||||
title,
|
||||
@@ -30,6 +31,7 @@ export function ReportEditButton({
|
||||
onDelete,
|
||||
}: {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
title?: ReactNode;
|
||||
@@ -43,7 +45,7 @@ export function ReportEditButton({
|
||||
const { t, labels, messages } = useMessages();
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const { mutateAsync, touch } = useDeleteQuery(`/reports/${id}`);
|
||||
const { mutateAsync, touch } = useDeleteQuery(`/websites/${websiteId}/${type}s/${id}`);
|
||||
|
||||
const handleAction = (id: any) => {
|
||||
if (id === 'edit') {
|
||||
@@ -61,7 +63,7 @@ export function ReportEditButton({
|
||||
const handleDelete = async () => {
|
||||
await mutateAsync(null, {
|
||||
onSuccess: async () => {
|
||||
touch(`reports:${type}`);
|
||||
touch(`websites:${type}s`);
|
||||
setShowDelete(false);
|
||||
onDelete?.();
|
||||
},
|
||||
@@ -0,0 +1,39 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { serializeAnalyticsQuery } from './analytics-query';
|
||||
import { funnelQuerySchema } from './analytics-schema';
|
||||
|
||||
test('structured funnel criteria survive GET URL encoding and validation', () => {
|
||||
const steps = [
|
||||
{
|
||||
type: 'path',
|
||||
value: '/pricing?plan=a&b="c"',
|
||||
filters: [{ property: 'a.b', operator: 'eq', value: '日本語 + / ? #' }],
|
||||
},
|
||||
{ type: 'event', value: 'signup' },
|
||||
];
|
||||
const query = serializeAnalyticsQuery({
|
||||
websiteId: 'website',
|
||||
startDate: new Date(1000),
|
||||
endDate: new Date(2000),
|
||||
steps,
|
||||
window: 60,
|
||||
browser1: 'eq.Chrome',
|
||||
unused: undefined,
|
||||
});
|
||||
const url = new URL(`https://example.org?${new URLSearchParams(query)}`);
|
||||
expect(funnelQuerySchema.parse(Object.fromEntries(url.searchParams)).steps).toEqual(steps);
|
||||
expect(url.searchParams.get('browser1')).toBe('eq.Chrome');
|
||||
expect(url.searchParams.has('websiteId')).toBe(false);
|
||||
expect(url.searchParams.has('unused')).toBe(false);
|
||||
});
|
||||
|
||||
test('explicit dates win over default timestamps', () => {
|
||||
expect(
|
||||
serializeAnalyticsQuery({
|
||||
startAt: 0,
|
||||
endAt: 100,
|
||||
startDate: new Date(1000),
|
||||
endDate: new Date(2000),
|
||||
}),
|
||||
).toEqual({ startAt: 1000, endAt: 2000 });
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface AnalyticsParameters {
|
||||
websiteId: string;
|
||||
startDate?: Date | string;
|
||||
endDate?: Date | string;
|
||||
startAt?: number;
|
||||
endAt?: number;
|
||||
unit?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
/** Encode structured GET criteria once, before URLSearchParams escapes them. */
|
||||
export function serializeAnalyticsQuery(params: Record<string, any>) {
|
||||
const { websiteId: _websiteId, id: _id, startDate, endDate, ...query } = params;
|
||||
if (startDate != null) query.startAt = +new Date(startDate);
|
||||
if (endDate != null) query.endAt = +new Date(endDate);
|
||||
return Object.fromEntries(
|
||||
Object.entries(query)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key, value]) => [
|
||||
key,
|
||||
typeof value === 'object' && value !== null ? JSON.stringify(value) : value,
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -12,13 +12,7 @@ export interface JourneyParameters {
|
||||
}
|
||||
|
||||
export interface JourneyResult {
|
||||
e1: string;
|
||||
e2: string;
|
||||
e3: string;
|
||||
e4: string;
|
||||
e5: string;
|
||||
e6: string;
|
||||
e7: string;
|
||||
items: string[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user