mirror of
https://github.com/umami-software/umami.git
synced 2026-02-11 08:07:12 +01:00
Added attribution report page.
This commit is contained in:
parent
0027502707
commit
79ea9974b7
23 changed files with 445 additions and 646 deletions
|
|
@ -36,7 +36,7 @@ export function WebsiteFilterButton({
|
||||||
<Icon>
|
<Icon>
|
||||||
<ListFilter />
|
<ListFilter />
|
||||||
</Icon>
|
</Icon>
|
||||||
{showText && <Text weight="bold">{formatMessage(labels.filter)}</Text>}
|
{showText && <Text>{formatMessage(labels.filter)}</Text>}
|
||||||
</Button>
|
</Button>
|
||||||
<Modal>
|
<Modal>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { Grid, Column, Heading } from '@umami/react-zen';
|
||||||
|
import { useMessages, useResultQuery } from '@/components/hooks';
|
||||||
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
|
import { formatLongNumber } from '@/lib/format';
|
||||||
|
import { CHART_COLORS } from '@/lib/constants';
|
||||||
|
|
||||||
|
import { PieChart } from '@/components/charts/PieChart';
|
||||||
|
import { ListTable } from '@/components/metrics/ListTable';
|
||||||
|
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||||
|
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
||||||
|
|
||||||
|
export interface AttributionProps {
|
||||||
|
websiteId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
model: string;
|
||||||
|
type: string;
|
||||||
|
step: string;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Attribution({
|
||||||
|
websiteId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
model,
|
||||||
|
type,
|
||||||
|
step,
|
||||||
|
currency,
|
||||||
|
}: AttributionProps) {
|
||||||
|
const { data, error, isLoading } = useResultQuery<any>('attribution', {
|
||||||
|
websiteId,
|
||||||
|
dateRange: {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
model,
|
||||||
|
type,
|
||||||
|
step,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const isEmpty = !Object.keys(data || {}).length;
|
||||||
|
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const ATTRIBUTION_PARAMS = [
|
||||||
|
{ value: 'referrer', label: formatMessage(labels.referrers) },
|
||||||
|
{ value: 'paidAds', label: formatMessage(labels.paidAds) },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pageviews, visitors, visits } = data.total;
|
||||||
|
|
||||||
|
const metrics = data
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: pageviews,
|
||||||
|
label: formatMessage(labels.views),
|
||||||
|
formatValue: formatLongNumber,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: visits,
|
||||||
|
label: formatMessage(labels.visits),
|
||||||
|
formatValue: formatLongNumber,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: visitors,
|
||||||
|
label: formatMessage(labels.visitors),
|
||||||
|
formatValue: formatLongNumber,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
function UTMTable(UTMTableProps: { data: any; title: string; utm: string }) {
|
||||||
|
const { data, title, utm } = UTMTableProps;
|
||||||
|
const total = data[utm].reduce((sum, { value }) => {
|
||||||
|
return +sum + +value;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListTable
|
||||||
|
title={title}
|
||||||
|
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
|
||||||
|
currency={currency}
|
||||||
|
data={data[utm].map(({ name, value }) => ({
|
||||||
|
x: name,
|
||||||
|
y: Number(value),
|
||||||
|
z: (value / total) * 100,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingPanel isEmpty={isEmpty} isLoading={isLoading} error={error}>
|
||||||
|
<Column gap>
|
||||||
|
<Panel>
|
||||||
|
<MetricsBar isFetched={data}>
|
||||||
|
{metrics?.map(({ label, value, formatValue }) => {
|
||||||
|
return (
|
||||||
|
<MetricCard key={label} value={value} label={label} formatValue={formatValue} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</MetricsBar>
|
||||||
|
</Panel>
|
||||||
|
{ATTRIBUTION_PARAMS.map(({ value, label }) => {
|
||||||
|
const items = data[value];
|
||||||
|
const total = items.reduce((sum, { value }) => {
|
||||||
|
return +sum + +value;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels: items.map(({ name }) => name),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: items.map(({ value }) => value),
|
||||||
|
backgroundColor: CHART_COLORS,
|
||||||
|
borderWidth: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel key={value}>
|
||||||
|
<Heading>{label}</Heading>
|
||||||
|
<Grid columns="1fr 1fr" gap>
|
||||||
|
<ListTable
|
||||||
|
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
|
||||||
|
currency={currency}
|
||||||
|
data={items.map(({ name, value }) => ({
|
||||||
|
x: name,
|
||||||
|
y: Number(value),
|
||||||
|
z: (value / total) * 100,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<PieChart type="doughnut" data={chartData} isLoading={isLoading} />
|
||||||
|
</Grid>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Grid gap>
|
||||||
|
<Grid columns="1fr 1fr" gap>
|
||||||
|
<Panel>
|
||||||
|
<UTMTable data={data} title={formatMessage(labels.sources)} utm={'utm_source'} />
|
||||||
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<UTMTable data={data} title={formatMessage(labels.medium)} utm={'utm_medium'} />
|
||||||
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<UTMTable data={data} title={formatMessage(labels.campaigns)} utm={'utm_campaign'} />
|
||||||
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<UTMTable data={data} title={formatMessage(labels.content)} utm={'utm_content'} />
|
||||||
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<UTMTable data={data} title={formatMessage(labels.terms)} utm={'utm_term'} />
|
||||||
|
</Panel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Column>
|
||||||
|
</LoadingPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Column, Grid, Select, ListItem, SearchField } from '@umami/react-zen';
|
||||||
|
import { Attribution } from './Attribution';
|
||||||
|
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||||
|
import { useDateRange, useMessages } from '@/components/hooks';
|
||||||
|
|
||||||
|
export function AttributionPage({ websiteId }: { websiteId: string }) {
|
||||||
|
const [model, setModel] = useState('first-click');
|
||||||
|
const [type, setType] = useState('page');
|
||||||
|
const [step, setStep] = useState('');
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const {
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
} = useDateRange(websiteId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column gap>
|
||||||
|
<WebsiteControls websiteId={websiteId} />
|
||||||
|
<Grid columns="1fr 1fr 1fr" gap>
|
||||||
|
<Column>
|
||||||
|
<Select
|
||||||
|
label={formatMessage(labels.model)}
|
||||||
|
value={model}
|
||||||
|
defaultValue={model}
|
||||||
|
onChange={setModel}
|
||||||
|
>
|
||||||
|
<ListItem id="first-click">{formatMessage(labels.firstClick)}</ListItem>
|
||||||
|
<ListItem id="last-click">{formatMessage(labels.lastClick)}</ListItem>
|
||||||
|
</Select>
|
||||||
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<Select
|
||||||
|
label={formatMessage(labels.type)}
|
||||||
|
value={type}
|
||||||
|
defaultValue={type}
|
||||||
|
onChange={setType}
|
||||||
|
>
|
||||||
|
<ListItem id="page">{formatMessage(labels.page)}</ListItem>
|
||||||
|
<ListItem id="event">{formatMessage(labels.event)}</ListItem>
|
||||||
|
</Select>
|
||||||
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<SearchField
|
||||||
|
label={formatMessage(labels.conversionStep)}
|
||||||
|
value={step}
|
||||||
|
onSearch={setStep}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
<Attribution
|
||||||
|
websiteId={websiteId}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
model={model}
|
||||||
|
type={type}
|
||||||
|
step={step}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
import { Grid, Row, Column, Text, Icon, ProgressBar, Dialog } from '@umami/react-zen';
|
|
||||||
import { ReportEditButton } from '@/components/input/ReportEditButton';
|
|
||||||
import { useMessages, useResultQuery } from '@/components/hooks';
|
|
||||||
import { File, Lightning, User } from '@/components/icons';
|
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
|
||||||
import { formatLongNumber } from '@/lib/format';
|
|
||||||
import { GoalEditForm } from './GoalEditForm';
|
|
||||||
|
|
||||||
export interface GoalProps {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
parameters: {
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
websiteId: string;
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GoalData = { num: number; total: number };
|
|
||||||
|
|
||||||
export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { data, error, isLoading } = useResultQuery<GoalData>(type, {
|
|
||||||
websiteId,
|
|
||||||
dateRange: {
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
},
|
|
||||||
parameters,
|
|
||||||
});
|
|
||||||
const isPage = parameters?.type === 'page';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LoadingPanel isEmpty={!data} isLoading={isLoading} error={error}>
|
|
||||||
<Grid gap>
|
|
||||||
<Grid columns="1fr auto" gap>
|
|
||||||
<Column gap>
|
|
||||||
<Row>
|
|
||||||
<Text size="4" weight="bold">
|
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
</Row>
|
|
||||||
</Column>
|
|
||||||
<Column>
|
|
||||||
<ReportEditButton id={id} name={name} type={type}>
|
|
||||||
{({ close }) => {
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
title={formatMessage(labels.goal)}
|
|
||||||
variant="modal"
|
|
||||||
style={{ minHeight: 300, minWidth: 400 }}
|
|
||||||
>
|
|
||||||
<GoalEditForm id={id} websiteId={websiteId} onClose={close} />
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</ReportEditButton>
|
|
||||||
</Column>
|
|
||||||
</Grid>
|
|
||||||
<Row alignItems="center" justifyContent="space-between" gap>
|
|
||||||
<Text color="muted">
|
|
||||||
{formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
|
|
||||||
</Text>
|
|
||||||
<Text color="muted">{formatMessage(labels.conversionRate)}</Text>
|
|
||||||
</Row>
|
|
||||||
<Row alignItems="center" justifyContent="space-between" gap>
|
|
||||||
<Row alignItems="center" gap>
|
|
||||||
<Icon>{parameters.type === 'page' ? <File /> : <Lightning />}</Icon>
|
|
||||||
<Text>{parameters.value}</Text>
|
|
||||||
</Row>
|
|
||||||
<Row alignItems="center" gap>
|
|
||||||
<Icon>
|
|
||||||
<User />
|
|
||||||
</Icon>
|
|
||||||
<Text title={`${data?.num} / ${data?.total}`}>{`${formatLongNumber(
|
|
||||||
data?.num,
|
|
||||||
)} / ${formatLongNumber(data?.total)}`}</Text>
|
|
||||||
</Row>
|
|
||||||
</Row>
|
|
||||||
<Row alignItems="center" gap="6">
|
|
||||||
<ProgressBar
|
|
||||||
value={data?.num || 0}
|
|
||||||
minValue={0}
|
|
||||||
maxValue={data?.total || 1}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
<Text weight="bold" size="7">
|
|
||||||
{data?.total ? Math.round((+data?.num / +data?.total) * 100) : '0'}%
|
|
||||||
</Text>
|
|
||||||
</Row>
|
|
||||||
</Grid>
|
|
||||||
</LoadingPanel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { Button, DialogTrigger, Dialog, Icon, Text, Modal } from '@umami/react-zen';
|
|
||||||
import { useMessages } from '@/components/hooks';
|
|
||||||
import { GoalEditForm } from './GoalEditForm';
|
|
||||||
import { Plus } from '@/components/icons';
|
|
||||||
|
|
||||||
export function GoalAddButton({ websiteId }: { websiteId: string }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogTrigger>
|
|
||||||
<Button variant="primary">
|
|
||||||
<Icon>
|
|
||||||
<Plus />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.goal)}</Text>
|
|
||||||
</Button>
|
|
||||||
<Modal>
|
|
||||||
<Dialog
|
|
||||||
variant="modal"
|
|
||||||
title={formatMessage(labels.goal)}
|
|
||||||
style={{ minHeight: 375, minWidth: 400 }}
|
|
||||||
>
|
|
||||||
{({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
|
|
||||||
</Dialog>
|
|
||||||
</Modal>
|
|
||||||
</DialogTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormField,
|
|
||||||
TextField,
|
|
||||||
Grid,
|
|
||||||
FormButtons,
|
|
||||||
FormSubmitButton,
|
|
||||||
Button,
|
|
||||||
RadioGroup,
|
|
||||||
Radio,
|
|
||||||
Text,
|
|
||||||
Icon,
|
|
||||||
Loading,
|
|
||||||
} from '@umami/react-zen';
|
|
||||||
import { useApi, useMessages, useModified, useReportQuery } from '@/components/hooks';
|
|
||||||
import { File, Lightning } from '@/components/icons';
|
|
||||||
|
|
||||||
export function GoalEditForm({
|
|
||||||
id,
|
|
||||||
websiteId,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
id?: string;
|
|
||||||
websiteId: string;
|
|
||||||
onSave?: () => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
}) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { touch } = useModified();
|
|
||||||
const { post, useMutation } = useApi();
|
|
||||||
const { data } = useReportQuery(id);
|
|
||||||
const { mutate, error, isPending } = useMutation({
|
|
||||||
mutationFn: (params: any) => post(`/reports${id ? `/${id}` : ''}`, params),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async ({ name, ...parameters }) => {
|
|
||||||
mutate(
|
|
||||||
{ ...data, id, name, type: 'goal', websiteId, parameters },
|
|
||||||
{
|
|
||||||
onSuccess: async () => {
|
|
||||||
if (id) touch(`report:${id}`);
|
|
||||||
touch('reports:goal');
|
|
||||||
onSave?.();
|
|
||||||
onClose?.();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (id && !data) {
|
|
||||||
return <Loading position="page" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultValues = {
|
|
||||||
name: data?.name || '',
|
|
||||||
type: data?.parameters?.type || 'page',
|
|
||||||
value: data?.parameters?.value || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}>
|
|
||||||
{({ watch }) => {
|
|
||||||
const watchType = watch('type');
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
name="name"
|
|
||||||
label={formatMessage(labels.name)}
|
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
|
||||||
>
|
|
||||||
<TextField />
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
name="type"
|
|
||||||
label={formatMessage(labels.type)}
|
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
|
||||||
>
|
|
||||||
<RadioGroup orientation="horizontal" variant="box">
|
|
||||||
<Grid columns="1fr 1fr" flexGrow={1} gap>
|
|
||||||
<Radio value="page">
|
|
||||||
<Icon>
|
|
||||||
<File />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.page)}</Text>
|
|
||||||
</Radio>
|
|
||||||
<Radio value="event">
|
|
||||||
<Icon>
|
|
||||||
<Lightning />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.event)}</Text>
|
|
||||||
</Radio>
|
|
||||||
</Grid>
|
|
||||||
</RadioGroup>
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
name="value"
|
|
||||||
label={formatMessage(watchType === 'event' ? labels.eventName : labels.path)}
|
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
|
||||||
>
|
|
||||||
<TextField />
|
|
||||||
</FormField>
|
|
||||||
<FormButtons>
|
|
||||||
<Button onPress={onClose} isDisabled={isPending}>
|
|
||||||
{formatMessage(labels.cancel)}
|
|
||||||
</Button>
|
|
||||||
<FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
|
|
||||||
</FormButtons>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
'use client';
|
|
||||||
import { Grid, Loading } from '@umami/react-zen';
|
|
||||||
import { SectionHeader } from '@/components/common/SectionHeader';
|
|
||||||
import { Goal } from './Goal';
|
|
||||||
import { GoalAddButton } from './GoalAddButton';
|
|
||||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
|
||||||
import { useDateRange, useReportsQuery } from '@/components/hooks';
|
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
|
||||||
import { Panel } from '@/components/common/Panel';
|
|
||||||
|
|
||||||
export function GoalsPage({ websiteId }: { websiteId: string }) {
|
|
||||||
const { result } = useReportsQuery({ websiteId, type: 'goal' });
|
|
||||||
const {
|
|
||||||
dateRange: { startDate, endDate },
|
|
||||||
} = useDateRange(websiteId);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return <Loading position="page" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<WebsiteControls websiteId={websiteId} />
|
|
||||||
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
|
|
||||||
<SectionHeader>
|
|
||||||
<GoalAddButton websiteId={websiteId} />
|
|
||||||
</SectionHeader>
|
|
||||||
<Grid columns="1fr 1fr" gap>
|
|
||||||
{result?.data?.map((report: any) => (
|
|
||||||
<Panel key={report.id}>
|
|
||||||
<Goal {...report} reportId={report.id} startDate={startDate} endDate={endDate} />
|
|
||||||
</Panel>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
</LoadingPanel>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { GoalsPage } from './GoalsPage';
|
import { AttributionPage } from './AttributionPage';
|
||||||
|
|
||||||
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||||
const { websiteId } = await params;
|
const { websiteId } = await params;
|
||||||
|
|
||||||
return <GoalsPage websiteId={websiteId} />;
|
return <AttributionPage websiteId={websiteId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Goals',
|
title: 'Attribution',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@ export function FunnelsPage({ websiteId }: { websiteId: string }) {
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<WebsiteControls websiteId={websiteId} />
|
<WebsiteControls websiteId={websiteId} />
|
||||||
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
|
<SectionHeader>
|
||||||
<SectionHeader>
|
<FunnelAddButton websiteId={websiteId} />
|
||||||
<FunnelAddButton websiteId={websiteId} />
|
</SectionHeader>
|
||||||
</SectionHeader>
|
<LoadingPanel isEmpty={!result?.data?.length} isLoading={!result}>
|
||||||
<Grid gap>
|
<Grid gap>
|
||||||
{result?.data?.map((report: any) => (
|
{result?.data?.map((report: any) => (
|
||||||
<Panel key={report.id}>
|
<Panel key={report.id}>
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@ export function GoalsPage({ websiteId }: { websiteId: string }) {
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<WebsiteControls websiteId={websiteId} />
|
<WebsiteControls websiteId={websiteId} />
|
||||||
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
|
<SectionHeader>
|
||||||
<SectionHeader>
|
<GoalAddButton websiteId={websiteId} />
|
||||||
<GoalAddButton websiteId={websiteId} />
|
</SectionHeader>
|
||||||
</SectionHeader>
|
<LoadingPanel isEmpty={!result?.data?.length} isLoading={!result}>
|
||||||
<Grid columns="1fr 1fr" gap>
|
<Grid columns="1fr 1fr" gap>
|
||||||
{result?.data?.map((report: any) => (
|
{result?.data?.map((report: any) => (
|
||||||
<Panel key={report.id}>
|
<Panel key={report.id}>
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
import { Grid, Row, Column, Text, Icon, ProgressBar, Dialog } from '@umami/react-zen';
|
|
||||||
import { ReportEditButton } from '@/components/input/ReportEditButton';
|
|
||||||
import { useMessages, useResultQuery } from '@/components/hooks';
|
|
||||||
import { File, Lightning, User } from '@/components/icons';
|
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
|
||||||
import { formatLongNumber } from '@/lib/format';
|
|
||||||
import { GoalEditForm } from './GoalEditForm';
|
|
||||||
|
|
||||||
export interface GoalProps {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
parameters: {
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
websiteId: string;
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GoalData = { num: number; total: number };
|
|
||||||
|
|
||||||
export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { data, error, isLoading } = useResultQuery<GoalData>(type, {
|
|
||||||
websiteId,
|
|
||||||
dateRange: {
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
},
|
|
||||||
parameters,
|
|
||||||
});
|
|
||||||
const isPage = parameters?.type === 'page';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LoadingPanel isEmpty={!data} isLoading={isLoading} error={error}>
|
|
||||||
<Grid gap>
|
|
||||||
<Grid columns="1fr auto" gap>
|
|
||||||
<Column gap>
|
|
||||||
<Row>
|
|
||||||
<Text size="4" weight="bold">
|
|
||||||
{name}
|
|
||||||
</Text>
|
|
||||||
</Row>
|
|
||||||
</Column>
|
|
||||||
<Column>
|
|
||||||
<ReportEditButton id={id} name={name} type={type}>
|
|
||||||
{({ close }) => {
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
title={formatMessage(labels.goal)}
|
|
||||||
variant="modal"
|
|
||||||
style={{ minHeight: 300, minWidth: 400 }}
|
|
||||||
>
|
|
||||||
<GoalEditForm id={id} websiteId={websiteId} onClose={close} />
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</ReportEditButton>
|
|
||||||
</Column>
|
|
||||||
</Grid>
|
|
||||||
<Row alignItems="center" justifyContent="space-between" gap>
|
|
||||||
<Text color="muted">
|
|
||||||
{formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
|
|
||||||
</Text>
|
|
||||||
<Text color="muted">{formatMessage(labels.conversionRate)}</Text>
|
|
||||||
</Row>
|
|
||||||
<Row alignItems="center" justifyContent="space-between" gap>
|
|
||||||
<Row alignItems="center" gap>
|
|
||||||
<Icon>{parameters.type === 'page' ? <File /> : <Lightning />}</Icon>
|
|
||||||
<Text>{parameters.value}</Text>
|
|
||||||
</Row>
|
|
||||||
<Row alignItems="center" gap>
|
|
||||||
<Icon>
|
|
||||||
<User />
|
|
||||||
</Icon>
|
|
||||||
<Text title={`${data?.num} / ${data?.total}`}>{`${formatLongNumber(
|
|
||||||
data?.num,
|
|
||||||
)} / ${formatLongNumber(data?.total)}`}</Text>
|
|
||||||
</Row>
|
|
||||||
</Row>
|
|
||||||
<Row alignItems="center" gap="6">
|
|
||||||
<ProgressBar
|
|
||||||
value={data?.num || 0}
|
|
||||||
minValue={0}
|
|
||||||
maxValue={data?.total || 1}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
<Text weight="bold" size="7">
|
|
||||||
{data?.total ? Math.round((+data?.num / +data?.total) * 100) : '0'}%
|
|
||||||
</Text>
|
|
||||||
</Row>
|
|
||||||
</Grid>
|
|
||||||
</LoadingPanel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { Button, DialogTrigger, Dialog, Icon, Text, Modal } from '@umami/react-zen';
|
|
||||||
import { useMessages } from '@/components/hooks';
|
|
||||||
import { GoalEditForm } from './GoalEditForm';
|
|
||||||
import { Plus } from '@/components/icons';
|
|
||||||
|
|
||||||
export function GoalAddButton({ websiteId }: { websiteId: string }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogTrigger>
|
|
||||||
<Button variant="primary">
|
|
||||||
<Icon>
|
|
||||||
<Plus />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.goal)}</Text>
|
|
||||||
</Button>
|
|
||||||
<Modal>
|
|
||||||
<Dialog
|
|
||||||
variant="modal"
|
|
||||||
title={formatMessage(labels.goal)}
|
|
||||||
style={{ minHeight: 375, minWidth: 400 }}
|
|
||||||
>
|
|
||||||
{({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
|
|
||||||
</Dialog>
|
|
||||||
</Modal>
|
|
||||||
</DialogTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormField,
|
|
||||||
TextField,
|
|
||||||
Grid,
|
|
||||||
FormButtons,
|
|
||||||
FormSubmitButton,
|
|
||||||
Button,
|
|
||||||
RadioGroup,
|
|
||||||
Radio,
|
|
||||||
Text,
|
|
||||||
Icon,
|
|
||||||
Loading,
|
|
||||||
} from '@umami/react-zen';
|
|
||||||
import { useApi, useMessages, useModified, useReportQuery } from '@/components/hooks';
|
|
||||||
import { File, Lightning } from '@/components/icons';
|
|
||||||
|
|
||||||
export function GoalEditForm({
|
|
||||||
id,
|
|
||||||
websiteId,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
id?: string;
|
|
||||||
websiteId: string;
|
|
||||||
onSave?: () => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
}) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { touch } = useModified();
|
|
||||||
const { post, useMutation } = useApi();
|
|
||||||
const { data } = useReportQuery(id);
|
|
||||||
const { mutate, error, isPending } = useMutation({
|
|
||||||
mutationFn: (params: any) => post(`/reports${id ? `/${id}` : ''}`, params),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async ({ name, ...parameters }) => {
|
|
||||||
mutate(
|
|
||||||
{ ...data, id, name, type: 'goal', websiteId, parameters },
|
|
||||||
{
|
|
||||||
onSuccess: async () => {
|
|
||||||
if (id) touch(`report:${id}`);
|
|
||||||
touch('reports:goal');
|
|
||||||
onSave?.();
|
|
||||||
onClose?.();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (id && !data) {
|
|
||||||
return <Loading position="page" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultValues = {
|
|
||||||
name: data?.name || '',
|
|
||||||
type: data?.parameters?.type || 'page',
|
|
||||||
value: data?.parameters?.value || '',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}>
|
|
||||||
{({ watch }) => {
|
|
||||||
const watchType = watch('type');
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
name="name"
|
|
||||||
label={formatMessage(labels.name)}
|
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
|
||||||
>
|
|
||||||
<TextField />
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
name="type"
|
|
||||||
label={formatMessage(labels.type)}
|
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
|
||||||
>
|
|
||||||
<RadioGroup orientation="horizontal" variant="box">
|
|
||||||
<Grid columns="1fr 1fr" flexGrow={1} gap>
|
|
||||||
<Radio value="page">
|
|
||||||
<Icon>
|
|
||||||
<File />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.page)}</Text>
|
|
||||||
</Radio>
|
|
||||||
<Radio value="event">
|
|
||||||
<Icon>
|
|
||||||
<Lightning />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatMessage(labels.event)}</Text>
|
|
||||||
</Radio>
|
|
||||||
</Grid>
|
|
||||||
</RadioGroup>
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
name="value"
|
|
||||||
label={formatMessage(watchType === 'event' ? labels.eventName : labels.path)}
|
|
||||||
rules={{ required: formatMessage(labels.required) }}
|
|
||||||
>
|
|
||||||
<TextField />
|
|
||||||
</FormField>
|
|
||||||
<FormButtons>
|
|
||||||
<Button onPress={onClose} isDisabled={isPending}>
|
|
||||||
{formatMessage(labels.cancel)}
|
|
||||||
</Button>
|
|
||||||
<FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
|
|
||||||
</FormButtons>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
'use client';
|
|
||||||
import { Grid, Loading } from '@umami/react-zen';
|
|
||||||
import { SectionHeader } from '@/components/common/SectionHeader';
|
|
||||||
import { Goal } from './Goal';
|
|
||||||
import { GoalAddButton } from './GoalAddButton';
|
|
||||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
|
||||||
import { useDateRange, useReportsQuery } from '@/components/hooks';
|
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
|
||||||
import { Panel } from '@/components/common/Panel';
|
|
||||||
|
|
||||||
export function GoalsPage({ websiteId }: { websiteId: string }) {
|
|
||||||
const { result } = useReportsQuery({ websiteId, type: 'goal' });
|
|
||||||
const {
|
|
||||||
dateRange: { startDate, endDate },
|
|
||||||
} = useDateRange(websiteId);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return <Loading position="page" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<WebsiteControls websiteId={websiteId} />
|
|
||||||
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
|
|
||||||
<SectionHeader>
|
|
||||||
<GoalAddButton websiteId={websiteId} />
|
|
||||||
</SectionHeader>
|
|
||||||
<Grid columns="1fr 1fr" gap>
|
|
||||||
{result?.data?.map((report: any) => (
|
|
||||||
<Panel key={report.id}>
|
|
||||||
<Goal {...report} reportId={report.id} startDate={startDate} endDate={endDate} />
|
|
||||||
</Panel>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
</LoadingPanel>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Grid, Row, Column, Text, Loading, Icon } from '@umami/react-zen';
|
||||||
|
import { Empty } from '@/components/common/Empty';
|
||||||
|
import { Users } from '@/components/icons';
|
||||||
|
import { useMessages, useLocale, useResultQuery } from '@/components/hooks';
|
||||||
|
import { formatDate } from '@/lib/date';
|
||||||
|
import { formatLongNumber } from '@/lib/format';
|
||||||
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
|
|
||||||
|
const DAYS = [1, 2, 3, 4, 5, 6, 7, 14, 21, 28];
|
||||||
|
|
||||||
|
export interface AttributionProps {
|
||||||
|
websiteId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
days?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Insights({ websiteId, days = DAYS, startDate, endDate }: AttributionProps) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { locale } = useLocale();
|
||||||
|
const { data, error, isLoading } = useResultQuery<any>('insights', {
|
||||||
|
websiteId,
|
||||||
|
dateRange: {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
days,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Loading position="page" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <Empty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = data.reduce((arr: any[], row: { date: any; visitors: any; day: any }) => {
|
||||||
|
const { date, visitors, day } = row;
|
||||||
|
if (day === 0) {
|
||||||
|
return arr.concat({
|
||||||
|
date,
|
||||||
|
visitors,
|
||||||
|
records: days
|
||||||
|
.reduce((arr, day) => {
|
||||||
|
arr[day] = data.find(x => x.date === date && x.day === day);
|
||||||
|
return arr;
|
||||||
|
}, [])
|
||||||
|
.filter(n => n),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalDays = rows.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingPanel isEmpty={!data?.length} isLoading={isLoading} error={error}>
|
||||||
|
<Panel allowFullscreen height="900px">
|
||||||
|
<Column gap="1" width="100%" overflow="auto">
|
||||||
|
<Grid
|
||||||
|
columns="120px repeat(10, 100px)"
|
||||||
|
alignItems="center"
|
||||||
|
gap="1"
|
||||||
|
height="50px"
|
||||||
|
autoFlow="column"
|
||||||
|
>
|
||||||
|
<Column>
|
||||||
|
<Text weight="bold">{formatMessage(labels.cohort)}</Text>
|
||||||
|
</Column>
|
||||||
|
{days.map(n => (
|
||||||
|
<Column key={n}>
|
||||||
|
<Text weight="bold" align="center" wrap="nowrap">
|
||||||
|
{formatMessage(labels.day)} {n}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
{rows.map(({ date, visitors, records }: any, rowIndex: number) => {
|
||||||
|
return (
|
||||||
|
<Grid key={rowIndex} columns="120px repeat(10, 100px)" gap="1" autoFlow="column">
|
||||||
|
<Column justifyContent="center" gap="1">
|
||||||
|
<Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
|
||||||
|
<Row alignItems="center" gap>
|
||||||
|
<Icon>
|
||||||
|
<Users />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatLongNumber(visitors)}</Text>
|
||||||
|
</Row>
|
||||||
|
</Column>
|
||||||
|
{days.map(day => {
|
||||||
|
if (totalDays - rowIndex < day) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const percentage = records.filter(a => a.day === day)[0]?.percentage;
|
||||||
|
return (
|
||||||
|
<Cell key={day}>{percentage ? `${Number(percentage).toFixed(2)}%` : ''}</Cell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Column>
|
||||||
|
</Panel>
|
||||||
|
</LoadingPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Cell = ({ children }: { children: ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<Column
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
width="100px"
|
||||||
|
height="100px"
|
||||||
|
backgroundColor="2"
|
||||||
|
borderRadius
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
'use client';
|
||||||
|
import { Column } from '@umami/react-zen';
|
||||||
|
import { Insights } from './Insights';
|
||||||
|
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||||
|
import { useDateRange } from '@/components/hooks';
|
||||||
|
|
||||||
|
export function InsightsPage({ websiteId }: { websiteId: string }) {
|
||||||
|
const {
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
} = useDateRange(websiteId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column gap>
|
||||||
|
<WebsiteControls websiteId={websiteId} />
|
||||||
|
<Insights websiteId={websiteId} startDate={startDate} endDate={endDate} />
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { GoalsPage } from './GoalsPage';
|
import { InsightsPage } from './InsightsPage';
|
||||||
|
|
||||||
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||||
const { websiteId } = await params;
|
const { websiteId } = await params;
|
||||||
|
|
||||||
return <GoalsPage websiteId={websiteId} />;
|
return <InsightsPage websiteId={websiteId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Goals',
|
title: 'Insights',
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,11 @@
|
||||||
import { canViewWebsite } from '@/lib/auth';
|
import { canViewWebsite } from '@/lib/auth';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { json, unauthorized } from '@/lib/response';
|
import { json, unauthorized } from '@/lib/response';
|
||||||
import { reportParms } from '@/lib/schema';
|
import { reportResultSchema } from '@/lib/schema';
|
||||||
import { getAttribution } from '@/queries/sql/reports/getAttribution';
|
import { getAttribution } from '@/queries/sql/reports/getAttribution';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const schema = z.object({
|
const { auth, body, error } = await parseRequest(request, reportResultSchema);
|
||||||
...reportParms,
|
|
||||||
model: z.string().regex(/firstClick|lastClick/i),
|
|
||||||
steps: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
type: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.min(1),
|
|
||||||
currency: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return error();
|
return error();
|
||||||
|
|
@ -28,10 +13,8 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
websiteId,
|
websiteId,
|
||||||
model,
|
|
||||||
steps,
|
|
||||||
currency,
|
|
||||||
dateRange: { startDate, endDate },
|
dateRange: { startDate, endDate },
|
||||||
|
parameters: { model, type, step, currency },
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!(await canViewWebsite(auth, websiteId))) {
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
|
@ -41,8 +24,9 @@ export async function POST(request: Request) {
|
||||||
const data = await getAttribution(websiteId, {
|
const data = await getAttribution(websiteId, {
|
||||||
startDate: new Date(startDate),
|
startDate: new Date(startDate),
|
||||||
endDate: new Date(endDate),
|
endDate: new Date(endDate),
|
||||||
model: model,
|
model,
|
||||||
steps,
|
type,
|
||||||
|
step,
|
||||||
currency,
|
currency,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,14 @@ export function Empty({ message }: EmptyProps) {
|
||||||
const { formatMessage, messages } = useMessages();
|
const { formatMessage, messages } = useMessages();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row color="muted" alignItems="center" justifyContent="center" width="100%" height="100%">
|
<Row
|
||||||
|
color="muted"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
minHeight="70px"
|
||||||
|
>
|
||||||
{message || formatMessage(messages.noDataAvailable)}
|
{message || formatMessage(messages.noDataAvailable)}
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ export function DateFilter({
|
||||||
placeholder={formatMessage(labels.selectDate)}
|
placeholder={formatMessage(labels.selectDate)}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
renderValue={renderValue}
|
renderValue={renderValue}
|
||||||
popoverProps={{ style: { width: 200 } }}
|
popoverProps={{ placement: 'top', style: { width: 200 } }}
|
||||||
>
|
>
|
||||||
{options.map(({ label, value, divider }: any) => {
|
{options.map(({ label, value, divider }: any) => {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -323,7 +323,9 @@ export const labels = defineMessages({
|
||||||
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
|
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
|
||||||
expand: { id: 'label.expand', defaultMessage: 'Expand' },
|
expand: { id: 'label.expand', defaultMessage: 'Expand' },
|
||||||
remaining: { id: 'label.remaining', defaultMessage: 'Remaining' },
|
remaining: { id: 'label.remaining', defaultMessage: 'Remaining' },
|
||||||
conversion: { id: 'label.converstion', defaultMessage: 'Conversion' },
|
conversion: { id: 'label.conversion', defaultMessage: 'Conversion' },
|
||||||
|
firstClick: { id: 'label.first-click', defaultMessage: 'First click' },
|
||||||
|
lastClick: { id: 'label.last-click', defaultMessage: 'Last click' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const messages = defineMessages({
|
export const messages = defineMessages({
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,12 @@ export const revenueReportSchema = z.object({
|
||||||
|
|
||||||
export const attributionReportSchema = z.object({
|
export const attributionReportSchema = z.object({
|
||||||
type: z.literal('attribution'),
|
type: z.literal('attribution'),
|
||||||
|
parameters: z.object({
|
||||||
|
model: z.enum(['first-click', 'last-click']),
|
||||||
|
type: z.enum(['page', 'event']),
|
||||||
|
step: z.string(),
|
||||||
|
currency: z.string().optional(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const insightsReportSchema = z.object({
|
export const insightsReportSchema = z.object({
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,16 @@ import { EVENT_TYPE } from '@/lib/constants';
|
||||||
import { CLICKHOUSE, getDatabaseType, POSTGRESQL, PRISMA, runQuery } from '@/lib/db';
|
import { CLICKHOUSE, getDatabaseType, POSTGRESQL, PRISMA, runQuery } from '@/lib/db';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
export async function getAttribution(
|
export interface AttributionCriteria {
|
||||||
...args: [
|
startDate: Date;
|
||||||
websiteId: string,
|
endDate: Date;
|
||||||
criteria: {
|
model: string;
|
||||||
startDate: Date;
|
type: string;
|
||||||
endDate: Date;
|
step: string;
|
||||||
model: string;
|
currency?: string;
|
||||||
steps: { type: string; value: string }[];
|
}
|
||||||
currency: string;
|
|
||||||
},
|
export async function getAttribution(...args: [websiteId: string, criteria: AttributionCriteria]) {
|
||||||
]
|
|
||||||
) {
|
|
||||||
return runQuery({
|
return runQuery({
|
||||||
[PRISMA]: () => relationalQuery(...args),
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||||
|
|
@ -23,13 +21,7 @@ export async function getAttribution(
|
||||||
|
|
||||||
async function relationalQuery(
|
async function relationalQuery(
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
criteria: {
|
criteria: AttributionCriteria,
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
model: string;
|
|
||||||
steps: { type: string; value: string }[];
|
|
||||||
currency: string;
|
|
||||||
},
|
|
||||||
): Promise<{
|
): Promise<{
|
||||||
referrer: { name: string; value: number }[];
|
referrer: { name: string; value: number }[];
|
||||||
paidAds: { name: string; value: number }[];
|
paidAds: { name: string; value: number }[];
|
||||||
|
|
@ -40,11 +32,10 @@ async function relationalQuery(
|
||||||
utm_term: { name: string; value: number }[];
|
utm_term: { name: string; value: number }[];
|
||||||
total: { pageviews: number; visitors: number; visits: number };
|
total: { pageviews: number; visitors: number; visits: number };
|
||||||
}> {
|
}> {
|
||||||
const { startDate, endDate, model, steps, currency } = criteria;
|
const { startDate, endDate, model, type, step, currency } = criteria;
|
||||||
const { rawQuery } = prisma;
|
const { rawQuery } = prisma;
|
||||||
const conversionStep = steps[0].value;
|
const eventType = type === 'url' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
|
||||||
const eventType = steps[0].type === 'url' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
|
const column = type === 'url' ? 'url_path' : 'event_name';
|
||||||
const column = steps[0].type === 'url' ? 'url_path' : 'event_name';
|
|
||||||
const db = getDatabaseType();
|
const db = getDatabaseType();
|
||||||
const like = db === POSTGRESQL ? 'ilike' : 'like';
|
const like = db === POSTGRESQL ? 'ilike' : 'like';
|
||||||
|
|
||||||
|
|
@ -147,7 +138,7 @@ async function relationalQuery(
|
||||||
order by 2 desc
|
order by 2 desc
|
||||||
limit 20
|
limit 20
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const paidAdsres = await rawQuery(
|
const paidAdsres = await rawQuery(
|
||||||
|
|
@ -180,7 +171,7 @@ async function relationalQuery(
|
||||||
FROM results
|
FROM results
|
||||||
${currency ? '' : `WHERE name != ''`}
|
${currency ? '' : `WHERE name != ''`}
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const sourceRes = await rawQuery(
|
const sourceRes = await rawQuery(
|
||||||
|
|
@ -189,7 +180,7 @@ async function relationalQuery(
|
||||||
${getModelQuery(model)}
|
${getModelQuery(model)}
|
||||||
${getUTMQuery('utm_source')}
|
${getUTMQuery('utm_source')}
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const mediumRes = await rawQuery(
|
const mediumRes = await rawQuery(
|
||||||
|
|
@ -198,7 +189,7 @@ async function relationalQuery(
|
||||||
${getModelQuery(model)}
|
${getModelQuery(model)}
|
||||||
${getUTMQuery('utm_medium')}
|
${getUTMQuery('utm_medium')}
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const campaignRes = await rawQuery(
|
const campaignRes = await rawQuery(
|
||||||
|
|
@ -207,7 +198,7 @@ async function relationalQuery(
|
||||||
${getModelQuery(model)}
|
${getModelQuery(model)}
|
||||||
${getUTMQuery('utm_campaign')}
|
${getUTMQuery('utm_campaign')}
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const contentRes = await rawQuery(
|
const contentRes = await rawQuery(
|
||||||
|
|
@ -216,7 +207,7 @@ async function relationalQuery(
|
||||||
${getModelQuery(model)}
|
${getModelQuery(model)}
|
||||||
${getUTMQuery('utm_content')}
|
${getUTMQuery('utm_content')}
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const termRes = await rawQuery(
|
const termRes = await rawQuery(
|
||||||
|
|
@ -225,7 +216,7 @@ async function relationalQuery(
|
||||||
${getModelQuery(model)}
|
${getModelQuery(model)}
|
||||||
${getUTMQuery('utm_term')}
|
${getUTMQuery('utm_term')}
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalRes = await rawQuery(
|
const totalRes = await rawQuery(
|
||||||
|
|
@ -240,7 +231,7 @@ async function relationalQuery(
|
||||||
and ${column} = {{conversionStep}}
|
and ${column} = {{conversionStep}}
|
||||||
and event_type = {{eventType}}
|
and event_type = {{eventType}}
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
).then(result => result?.[0]);
|
).then(result => result?.[0]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -257,13 +248,7 @@ async function relationalQuery(
|
||||||
|
|
||||||
async function clickhouseQuery(
|
async function clickhouseQuery(
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
criteria: {
|
criteria: AttributionCriteria,
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
model: string;
|
|
||||||
steps: { type: string; value: string }[];
|
|
||||||
currency: string;
|
|
||||||
},
|
|
||||||
): Promise<{
|
): Promise<{
|
||||||
referrer: { name: string; value: number }[];
|
referrer: { name: string; value: number }[];
|
||||||
paidAds: { name: string; value: number }[];
|
paidAds: { name: string; value: number }[];
|
||||||
|
|
@ -274,11 +259,10 @@ async function clickhouseQuery(
|
||||||
utm_term: { name: string; value: number }[];
|
utm_term: { name: string; value: number }[];
|
||||||
total: { pageviews: number; visitors: number; visits: number };
|
total: { pageviews: number; visitors: number; visits: number };
|
||||||
}> {
|
}> {
|
||||||
const { startDate, endDate, model, steps, currency } = criteria;
|
const { startDate, endDate, model, type, step, currency } = criteria;
|
||||||
const { rawQuery } = clickhouse;
|
const { rawQuery } = clickhouse;
|
||||||
const conversionStep = steps[0].value;
|
const eventType = type === 'url' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
|
||||||
const eventType = steps[0].type === 'url' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
|
const column = type === 'url' ? 'url_path' : 'event_name';
|
||||||
const column = steps[0].type === 'url' ? 'url_path' : 'event_name';
|
|
||||||
|
|
||||||
function getUTMQuery(utmColumn: string) {
|
function getUTMQuery(utmColumn: string) {
|
||||||
return `
|
return `
|
||||||
|
|
@ -372,7 +356,7 @@ async function clickhouseQuery(
|
||||||
order by 2 desc
|
order by 2 desc
|
||||||
limit 20
|
limit 20
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const paidAdsres = await rawQuery<
|
const paidAdsres = await rawQuery<
|
||||||
|
|
@ -403,7 +387,7 @@ async function clickhouseQuery(
|
||||||
order by 2 desc
|
order by 2 desc
|
||||||
limit 20
|
limit 20
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const sourceRes = await rawQuery<
|
const sourceRes = await rawQuery<
|
||||||
|
|
@ -417,7 +401,7 @@ async function clickhouseQuery(
|
||||||
${getModelQuery(model)}
|
${getModelQuery(model)}
|
||||||
${getUTMQuery('utm_source')}
|
${getUTMQuery('utm_source')}
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const mediumRes = await rawQuery<
|
const mediumRes = await rawQuery<
|
||||||
|
|
@ -431,7 +415,7 @@ async function clickhouseQuery(
|
||||||
${getModelQuery(model)}
|
${getModelQuery(model)}
|
||||||
${getUTMQuery('utm_medium')}
|
${getUTMQuery('utm_medium')}
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const campaignRes = await rawQuery<
|
const campaignRes = await rawQuery<
|
||||||
|
|
@ -445,7 +429,7 @@ async function clickhouseQuery(
|
||||||
${getModelQuery(model)}
|
${getModelQuery(model)}
|
||||||
${getUTMQuery('utm_campaign')}
|
${getUTMQuery('utm_campaign')}
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const contentRes = await rawQuery<
|
const contentRes = await rawQuery<
|
||||||
|
|
@ -459,7 +443,7 @@ async function clickhouseQuery(
|
||||||
${getModelQuery(model)}
|
${getModelQuery(model)}
|
||||||
${getUTMQuery('utm_content')}
|
${getUTMQuery('utm_content')}
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const termRes = await rawQuery<
|
const termRes = await rawQuery<
|
||||||
|
|
@ -473,7 +457,7 @@ async function clickhouseQuery(
|
||||||
${getModelQuery(model)}
|
${getModelQuery(model)}
|
||||||
${getUTMQuery('utm_term')}
|
${getUTMQuery('utm_term')}
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalRes = await rawQuery<{ pageviews: number; visitors: number; visits: number }>(
|
const totalRes = await rawQuery<{ pageviews: number; visitors: number; visits: number }>(
|
||||||
|
|
@ -488,7 +472,7 @@ async function clickhouseQuery(
|
||||||
and ${column} = {conversionStep:String}
|
and ${column} = {conversionStep:String}
|
||||||
and event_type = {eventType:UInt32}
|
and event_type = {eventType:UInt32}
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
|
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
|
||||||
).then(result => result?.[0]);
|
).then(result => result?.[0]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue