diff --git a/src/app/(main)/reports/attribution/AttributionParameters.tsx b/src/app/(main)/reports/attribution/AttributionParameters.tsx
index f9f2915d..7817b85e 100644
--- a/src/app/(main)/reports/attribution/AttributionParameters.tsx
+++ b/src/app/(main)/reports/attribution/AttributionParameters.tsx
@@ -1,69 +1,91 @@
import { useMessages } from '@/components/hooks';
+import Icons from '@/components/icons';
import { useContext, useState } from 'react';
-import { Dropdown, Form, FormButtons, FormInput, FormRow, Item, SubmitButton } from 'react-basics';
+import {
+ Button,
+ Dropdown,
+ Form,
+ FormButtons,
+ FormInput,
+ FormRow,
+ Icon,
+ Item,
+ Popup,
+ PopupTrigger,
+ SubmitButton,
+} from 'react-basics';
import BaseParameters from '../[reportId]/BaseParameters';
+import ParameterList from '../[reportId]/ParameterList';
+import PopupForm from '../[reportId]/PopupForm';
import { ReportContext } from '../[reportId]/Report';
+import FunnelStepAddForm from '../funnel/FunnelStepAddForm';
+import styles from './AttributionParameters.module.css';
+import AttributionStepAddForm from './AttributionStepAddForm';
export function AttributionParameters() {
- const [model, setModel] = useState('firstClick');
- const { report, runReport, isRunning } = useContext(ReportContext);
+ const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {};
- const { websiteId, dateRange } = parameters || {};
- const queryEnabled = websiteId && dateRange;
+ const { websiteId, dateRange, steps } = parameters || {};
+ const queryEnabled = websiteId && dateRange && steps.length > 0;
+ const [model, setModel] = useState('');
const handleSubmit = (data: any, e: any) => {
e.stopPropagation();
e.preventDefault();
-
runReport(data);
};
- // const handleAddStep = (step: { type: string; value: string }) => {
- // updateReport({ parameters: { steps: parameters.steps.concat(step) } });
- // };
+ const handleAddStep = (step: { type: string; value: string }) => {
+ updateReport({ parameters: { steps: parameters.steps.concat(step) } });
+ };
- // const handleUpdateStep = (
- // close: () => void,
- // index: number,
- // step: { type: string; value: string },
- // ) => {
- // const steps = [...parameters.steps];
- // steps[index] = step;
- // updateReport({ parameters: { steps } });
- // close();
- // };
+ const handleUpdateStep = (
+ close: () => void,
+ index: number,
+ step: { type: string; value: string },
+ ) => {
+ const steps = [...parameters.steps];
+ steps[index] = step;
+ updateReport({ parameters: { steps } });
+ close();
+ };
- // const handleRemoveStep = (index: number) => {
- // const steps = [...parameters.steps];
- // delete steps[index];
- // updateReport({ parameters: { steps: steps.filter(n => n) } });
- // };
+ const handleRemoveStep = (index: number) => {
+ const steps = [...parameters.steps];
+ delete steps[index];
+ updateReport({ parameters: { steps: steps.filter(n => n) } });
+ };
- // const AddStepButton = () => {
- // return (
- //
- //
- //
- //
- //
- //
- //
- //
- // );
- // };
+ const AddStepButton = () => {
+ return (
+ 0}>
+
+
+
+
+
+
+
+ );
+ };
- const attributionModel = [
+ const items = [
{ label: 'First-Click', value: 'firstClick' },
{ label: 'Last-Click', value: 'lastClick' },
];
const renderModelValue = (value: any) => {
- return attributionModel.find(item => item.value === value)?.label;
+ return items.find(item => item.value === value)?.label;
+ };
+
+ const onModelChange = (value: any) => {
+ setModel(value);
+ updateReport({ parameters: { model } });
};
return (
@@ -72,10 +94,10 @@ export function AttributionParameters() {
setModel(value)}
- items={attributionModel}
+ onChange={onModelChange}
>
{({ value, label }) => {
return - {label}
;
@@ -83,7 +105,7 @@ export function AttributionParameters() {
- {/* }>
+ }>
{steps.map((step: { type: string; value: string }, index: number) => {
return (
@@ -100,7 +122,7 @@ export function AttributionParameters() {
{(close: () => void) => (
-
- */}
+
{formatMessage(labels.runQuery)}
diff --git a/src/app/(main)/reports/attribution/AttributionReport.tsx b/src/app/(main)/reports/attribution/AttributionReport.tsx
index 90b0b536..c33a4195 100644
--- a/src/app/(main)/reports/attribution/AttributionReport.tsx
+++ b/src/app/(main)/reports/attribution/AttributionReport.tsx
@@ -9,7 +9,7 @@ import AttributionView from './AttributionView';
const defaultParameters = {
type: REPORT_TYPES.attribution,
- parameters: {},
+ parameters: { model: 'firstClick', steps: [] },
};
export default function AttributionReport({ reportId }: { reportId?: string }) {
diff --git a/src/app/api/reports/attribution/route.ts b/src/app/api/reports/attribution/route.ts
new file mode 100644
index 00000000..6033c633
--- /dev/null
+++ b/src/app/api/reports/attribution/route.ts
@@ -0,0 +1,47 @@
+import { z } from 'zod';
+import { canViewWebsite } from '@/lib/auth';
+import { unauthorized, json } from '@/lib/response';
+import { parseRequest } from '@/lib/request';
+import { getFunnel } from '@/queries';
+import { reportParms } from '@/lib/schema';
+
+export async function POST(request: Request) {
+ const schema = z.object({
+ ...reportParms,
+ window: z.coerce.number().positive(),
+ steps: z
+ .array(
+ z.object({
+ type: z.string(),
+ value: z.string(),
+ }),
+ )
+ .min(2),
+ });
+
+ const { auth, body, error } = await parseRequest(request, schema);
+
+ if (error) {
+ return error();
+ }
+
+ const {
+ websiteId,
+ steps,
+ window,
+ dateRange: { startDate, endDate },
+ } = body;
+
+ if (!(await canViewWebsite(auth, websiteId))) {
+ return unauthorized();
+ }
+
+ const data = await getFunnel(websiteId, {
+ startDate: new Date(startDate),
+ endDate: new Date(endDate),
+ steps,
+ windowMinutes: +window,
+ });
+
+ return json(data);
+}
diff --git a/src/components/messages.ts b/src/components/messages.ts
index 653b0065..51ecc615 100644
--- a/src/components/messages.ts
+++ b/src/components/messages.ts
@@ -263,6 +263,7 @@ export const labels = defineMessages({
id: 'label.utm-description',
defaultMessage: 'Track your campaigns through UTM parameters.',
},
+ conversionStep: { id: 'label.conversion-step', defaultMessage: 'Conversion Step' },
steps: { id: 'label.steps', defaultMessage: 'Steps' },
startStep: { id: 'label.start-step', defaultMessage: 'Start Step' },
endStep: { id: 'label.end-step', defaultMessage: 'End Step' },
diff --git a/src/queries/sql/reports/getAttribution.ts b/src/queries/sql/reports/getAttribution.ts
new file mode 100644
index 00000000..70b51a9d
--- /dev/null
+++ b/src/queries/sql/reports/getAttribution.ts
@@ -0,0 +1,250 @@
+import clickhouse from '@/lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
+import prisma from '@/lib/prisma';
+
+const formatResults = (steps: { type: string; value: string }[]) => (results: unknown) => {
+ return steps.map((step: { type: string; value: string }, i: number) => {
+ const visitors = Number(results[i]?.count) || 0;
+ const previous = Number(results[i - 1]?.count) || 0;
+ const dropped = previous > 0 ? previous - visitors : 0;
+ const dropoff = 1 - visitors / previous;
+ const remaining = visitors / Number(results[0].count);
+
+ return {
+ ...step,
+ visitors,
+ previous,
+ dropped,
+ dropoff,
+ remaining,
+ };
+ });
+};
+
+export async function getFunnel(
+ ...args: [
+ websiteId: string,
+ criteria: {
+ windowMinutes: number;
+ startDate: Date;
+ endDate: Date;
+ steps: { type: string; value: string }[];
+ },
+ ]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ criteria: {
+ windowMinutes: number;
+ startDate: Date;
+ endDate: Date;
+ steps: { type: string; value: string }[];
+ },
+): Promise<
+ {
+ value: string;
+ visitors: number;
+ dropoff: number;
+ }[]
+> {
+ const { windowMinutes, startDate, endDate, steps } = criteria;
+ const { rawQuery, getAddIntervalQuery } = prisma;
+ const { levelOneQuery, levelQuery, sumQuery, params } = getFunnelQuery(steps, windowMinutes);
+
+ function getFunnelQuery(
+ steps: { type: string; value: string }[],
+ windowMinutes: number,
+ ): {
+ levelOneQuery: string;
+ levelQuery: string;
+ sumQuery: string;
+ params: string[];
+ } {
+ return steps.reduce(
+ (pv, cv, i) => {
+ const levelNumber = i + 1;
+ const startSum = i > 0 ? 'union ' : '';
+ const isURL = cv.type === 'url';
+ const column = isURL ? 'url_path' : 'event_name';
+
+ let operator = '=';
+ let paramValue = cv.value;
+
+ if (cv.value.startsWith('*') || cv.value.endsWith('*')) {
+ operator = 'like';
+ paramValue = cv.value.replace(/^\*|\*$/g, '%');
+ }
+
+ if (levelNumber === 1) {
+ pv.levelOneQuery = `
+ WITH level1 AS (
+ select distinct session_id, created_at
+ from website_event
+ where website_id = {{websiteId::uuid}}
+ and created_at between {{startDate}} and {{endDate}}
+ and ${column} ${operator} {{${i}}}
+ )`;
+ } else {
+ pv.levelQuery += `
+ , level${levelNumber} AS (
+ select distinct we.session_id, we.created_at
+ from level${i} l
+ join website_event we
+ on l.session_id = we.session_id
+ where we.website_id = {{websiteId::uuid}}
+ and we.created_at between l.created_at and ${getAddIntervalQuery(
+ `l.created_at `,
+ `${windowMinutes} minute`,
+ )}
+ and we.${column} ${operator} {{${i}}}
+ and we.created_at <= {{endDate}}
+ )`;
+ }
+
+ pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`;
+ pv.params.push(paramValue);
+
+ return pv;
+ },
+ {
+ levelOneQuery: '',
+ levelQuery: '',
+ sumQuery: '',
+ params: [],
+ },
+ );
+ }
+
+ return rawQuery(
+ `
+ ${levelOneQuery}
+ ${levelQuery}
+ ${sumQuery}
+ ORDER BY level;
+ `,
+ {
+ websiteId,
+ startDate,
+ endDate,
+ ...params,
+ },
+ ).then(formatResults(steps));
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ criteria: {
+ windowMinutes: number;
+ startDate: Date;
+ endDate: Date;
+ steps: { type: string; value: string }[];
+ },
+): Promise<
+ {
+ value: string;
+ visitors: number;
+ dropoff: number;
+ }[]
+> {
+ const { windowMinutes, startDate, endDate, steps } = criteria;
+ const { rawQuery } = clickhouse;
+ const { levelOneQuery, levelQuery, sumQuery, stepFilterQuery, params } = getFunnelQuery(
+ steps,
+ windowMinutes,
+ );
+
+ function getFunnelQuery(
+ steps: { type: string; value: string }[],
+ windowMinutes: number,
+ ): {
+ levelOneQuery: string;
+ levelQuery: string;
+ sumQuery: string;
+ stepFilterQuery: string;
+ params: { [key: string]: string };
+ } {
+ return steps.reduce(
+ (pv, cv, i) => {
+ const levelNumber = i + 1;
+ const startSum = i > 0 ? 'union all ' : '';
+ const startFilter = i > 0 ? 'or' : '';
+ const isURL = cv.type === 'url';
+ const column = isURL ? 'url_path' : 'event_name';
+
+ let operator = '=';
+ let paramValue = cv.value;
+
+ if (cv.value.startsWith('*') || cv.value.endsWith('*')) {
+ operator = 'like';
+ paramValue = cv.value.replace(/^\*|\*$/g, '%');
+ }
+
+ if (levelNumber === 1) {
+ pv.levelOneQuery = `\n
+ level1 AS (
+ select *
+ from level0
+ where ${column} ${operator} {param${i}:String}
+ )`;
+ } else {
+ pv.levelQuery += `\n
+ , level${levelNumber} AS (
+ select distinct y.session_id as session_id,
+ y.url_path as url_path,
+ y.referrer_path as referrer_path,
+ y.event_name,
+ y.created_at as created_at
+ from level${i} x
+ join level0 y
+ on x.session_id = y.session_id
+ where y.created_at between x.created_at and x.created_at + interval ${windowMinutes} minute
+ and y.${column} ${operator} {param${i}:String}
+ )`;
+ }
+
+ pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`;
+ pv.stepFilterQuery += `${startFilter} ${column} ${operator} {param${i}:String} `;
+ pv.params[`param${i}`] = paramValue;
+
+ return pv;
+ },
+ {
+ levelOneQuery: '',
+ levelQuery: '',
+ sumQuery: '',
+ stepFilterQuery: '',
+ params: {},
+ },
+ );
+ }
+
+ return rawQuery(
+ `
+ WITH level0 AS (
+ select distinct session_id, url_path, referrer_path, event_name, created_at
+ from umami.website_event
+ where (${stepFilterQuery})
+ and website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ),
+ ${levelOneQuery}
+ ${levelQuery}
+ select *
+ from (
+ ${sumQuery}
+ ) ORDER BY level;
+ `,
+ {
+ websiteId,
+ startDate,
+ endDate,
+ ...params,
+ },
+ ).then(formatResults(steps));
+}