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>
<ListFilter />
</Icon>
{showText && <Text weight="bold">{formatMessage(labels.filter)}</Text>}
{showText && <Text>{formatMessage(labels.filter)}</Text>}
</Button>
<Modal>
<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 { GoalsPage } from './GoalsPage';
import { AttributionPage } from './AttributionPage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
const { websiteId } = await params;
return <GoalsPage websiteId={websiteId} />;
return <AttributionPage websiteId={websiteId} />;
}
export const metadata: Metadata = {
title: 'Goals',
title: 'Attribution',
};

View file

@ -17,10 +17,10 @@ export function FunnelsPage({ websiteId }: { websiteId: string }) {
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
<SectionHeader>
<FunnelAddButton websiteId={websiteId} />
</SectionHeader>
<SectionHeader>
<FunnelAddButton websiteId={websiteId} />
</SectionHeader>
<LoadingPanel isEmpty={!result?.data?.length} isLoading={!result}>
<Grid gap>
{result?.data?.map((report: any) => (
<Panel key={report.id}>

View file

@ -17,10 +17,10 @@ export function GoalsPage({ websiteId }: { websiteId: string }) {
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
<SectionHeader>
<GoalAddButton websiteId={websiteId} />
</SectionHeader>
<SectionHeader>
<GoalAddButton websiteId={websiteId} />
</SectionHeader>
<LoadingPanel isEmpty={!result?.data?.length} isLoading={!result}>
<Grid columns="1fr 1fr" gap>
{result?.data?.map((report: any) => (
<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 { GoalsPage } from './GoalsPage';
import { InsightsPage } from './InsightsPage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
const { websiteId } = await params;
return <GoalsPage websiteId={websiteId} />;
return <InsightsPage websiteId={websiteId} />;
}
export const metadata: Metadata = {
title: 'Goals',
title: 'Insights',
};

View file

@ -1,26 +1,11 @@
import { canViewWebsite } from '@/lib/auth';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { reportParms } from '@/lib/schema';
import { reportResultSchema } from '@/lib/schema';
import { getAttribution } from '@/queries/sql/reports/getAttribution';
import { z } from 'zod';
export async function POST(request: Request) {
const schema = z.object({
...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);
const { auth, body, error } = await parseRequest(request, reportResultSchema);
if (error) {
return error();
@ -28,10 +13,8 @@ export async function POST(request: Request) {
const {
websiteId,
model,
steps,
currency,
dateRange: { startDate, endDate },
parameters: { model, type, step, currency },
} = body;
if (!(await canViewWebsite(auth, websiteId))) {
@ -41,8 +24,9 @@ export async function POST(request: Request) {
const data = await getAttribution(websiteId, {
startDate: new Date(startDate),
endDate: new Date(endDate),
model: model,
steps,
model,
type,
step,
currency,
});

View file

@ -9,7 +9,14 @@ export function Empty({ message }: EmptyProps) {
const { formatMessage, messages } = useMessages();
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)}
</Row>
);

View file

@ -106,7 +106,7 @@ export function DateFilter({
placeholder={formatMessage(labels.selectDate)}
onChange={handleChange}
renderValue={renderValue}
popoverProps={{ style: { width: 200 } }}
popoverProps={{ placement: 'top', style: { width: 200 } }}
>
{options.map(({ label, value, divider }: any) => {
return (

View file

@ -323,7 +323,9 @@ export const labels = defineMessages({
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
expand: { id: 'label.expand', defaultMessage: 'Expand' },
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({

View file

@ -133,6 +133,12 @@ export const revenueReportSchema = z.object({
export const attributionReportSchema = z.object({
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({

View file

@ -3,18 +3,16 @@ import { EVENT_TYPE } from '@/lib/constants';
import { CLICKHOUSE, getDatabaseType, POSTGRESQL, PRISMA, runQuery } from '@/lib/db';
import prisma from '@/lib/prisma';
export async function getAttribution(
...args: [
websiteId: string,
criteria: {
startDate: Date;
endDate: Date;
model: string;
steps: { type: string; value: string }[];
currency: string;
},
]
) {
export interface AttributionCriteria {
startDate: Date;
endDate: Date;
model: string;
type: string;
step: string;
currency?: string;
}
export async function getAttribution(...args: [websiteId: string, criteria: AttributionCriteria]) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
@ -23,13 +21,7 @@ export async function getAttribution(
async function relationalQuery(
websiteId: string,
criteria: {
startDate: Date;
endDate: Date;
model: string;
steps: { type: string; value: string }[];
currency: string;
},
criteria: AttributionCriteria,
): Promise<{
referrer: { name: string; value: number }[];
paidAds: { name: string; value: number }[];
@ -40,11 +32,10 @@ async function relationalQuery(
utm_term: { name: string; value: 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 conversionStep = steps[0].value;
const eventType = steps[0].type === 'url' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
const column = steps[0].type === 'url' ? 'url_path' : 'event_name';
const eventType = type === 'url' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
const column = type === 'url' ? 'url_path' : 'event_name';
const db = getDatabaseType();
const like = db === POSTGRESQL ? 'ilike' : 'like';
@ -147,7 +138,7 @@ async function relationalQuery(
order by 2 desc
limit 20
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
);
const paidAdsres = await rawQuery(
@ -180,7 +171,7 @@ async function relationalQuery(
FROM results
${currency ? '' : `WHERE name != ''`}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
);
const sourceRes = await rawQuery(
@ -189,7 +180,7 @@ async function relationalQuery(
${getModelQuery(model)}
${getUTMQuery('utm_source')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
);
const mediumRes = await rawQuery(
@ -198,7 +189,7 @@ async function relationalQuery(
${getModelQuery(model)}
${getUTMQuery('utm_medium')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
);
const campaignRes = await rawQuery(
@ -207,7 +198,7 @@ async function relationalQuery(
${getModelQuery(model)}
${getUTMQuery('utm_campaign')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
);
const contentRes = await rawQuery(
@ -216,7 +207,7 @@ async function relationalQuery(
${getModelQuery(model)}
${getUTMQuery('utm_content')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
);
const termRes = await rawQuery(
@ -225,7 +216,7 @@ async function relationalQuery(
${getModelQuery(model)}
${getUTMQuery('utm_term')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
);
const totalRes = await rawQuery(
@ -240,7 +231,7 @@ async function relationalQuery(
and ${column} = {{conversionStep}}
and event_type = {{eventType}}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
).then(result => result?.[0]);
return {
@ -257,13 +248,7 @@ async function relationalQuery(
async function clickhouseQuery(
websiteId: string,
criteria: {
startDate: Date;
endDate: Date;
model: string;
steps: { type: string; value: string }[];
currency: string;
},
criteria: AttributionCriteria,
): Promise<{
referrer: { name: string; value: number }[];
paidAds: { name: string; value: number }[];
@ -274,11 +259,10 @@ async function clickhouseQuery(
utm_term: { name: string; value: 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 conversionStep = steps[0].value;
const eventType = steps[0].type === 'url' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
const column = steps[0].type === 'url' ? 'url_path' : 'event_name';
const eventType = type === 'url' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
const column = type === 'url' ? 'url_path' : 'event_name';
function getUTMQuery(utmColumn: string) {
return `
@ -372,7 +356,7 @@ async function clickhouseQuery(
order by 2 desc
limit 20
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
);
const paidAdsres = await rawQuery<
@ -403,7 +387,7 @@ async function clickhouseQuery(
order by 2 desc
limit 20
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
);
const sourceRes = await rawQuery<
@ -417,7 +401,7 @@ async function clickhouseQuery(
${getModelQuery(model)}
${getUTMQuery('utm_source')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
);
const mediumRes = await rawQuery<
@ -431,7 +415,7 @@ async function clickhouseQuery(
${getModelQuery(model)}
${getUTMQuery('utm_medium')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
);
const campaignRes = await rawQuery<
@ -445,7 +429,7 @@ async function clickhouseQuery(
${getModelQuery(model)}
${getUTMQuery('utm_campaign')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
);
const contentRes = await rawQuery<
@ -459,7 +443,7 @@ async function clickhouseQuery(
${getModelQuery(model)}
${getUTMQuery('utm_content')}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
);
const termRes = await rawQuery<
@ -473,7 +457,7 @@ async function clickhouseQuery(
${getModelQuery(model)}
${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 }>(
@ -488,7 +472,7 @@ async function clickhouseQuery(
and ${column} = {conversionStep:String}
and event_type = {eventType:UInt32}
`,
{ websiteId, startDate, endDate, conversionStep, eventType, currency },
{ websiteId, startDate, endDate, conversionStep: step, eventType, currency },
).then(result => result?.[0]);
return {