Added attribution report page.

This commit is contained in:
Mike Cao 2025-06-09 00:42:09 -07:00
parent 0027502707
commit 79ea9974b7
23 changed files with 445 additions and 646 deletions

View file

@ -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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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',
}; };

View file

@ -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}>

View file

@ -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}>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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',
}; };

View file

@ -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,
}); });

View file

@ -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>
); );

View file

@ -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 (

View file

@ -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({

View file

@ -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({

View file

@ -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 {