diff --git a/ignore-build-step.sh b/ignore-build-step.sh
deleted file mode 100644
index da63c205..00000000
--- a/ignore-build-step.sh
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/bin/bash
-
-echo "VERCEL_GIT_COMMIT_REF: $VERCEL_GIT_COMMIT_REF"
-
-if [[ "$VERCEL_GIT_COMMIT_REF" != "analytics" ]] ; then
- # Proceed with the build
- echo "✅ - Build can proceed"
- exit 1;
-
-else
- # Don't build
- echo "🛑 - Build cancelled"
- exit 0;
-fi
\ No newline at end of file
diff --git a/src/app/(main)/reports/create/ReportTemplates.tsx b/src/app/(main)/reports/create/ReportTemplates.tsx
index fdf5c5f5..0777cc1f 100644
--- a/src/app/(main)/reports/create/ReportTemplates.tsx
+++ b/src/app/(main)/reports/create/ReportTemplates.tsx
@@ -6,6 +6,7 @@ import Lightbulb from 'assets/lightbulb.svg';
import Magnet from 'assets/magnet.svg';
import Tag from 'assets/tag.svg';
import Target from 'assets/target.svg';
+import Path from 'assets/path.svg';
import styles from './ReportTemplates.module.css';
import { useMessages, useTeamUrl } from 'components/hooks';
@@ -44,6 +45,12 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean })
url: renderTeamUrl('/reports/goals'),
icon: ,
},
+ {
+ title: formatMessage(labels.journey),
+ description: formatMessage(labels.journeyDescription),
+ url: renderTeamUrl('/reports/journey'),
+ icon: ,
+ },
];
return (
diff --git a/src/app/(main)/reports/journey/JourneyParameters.tsx b/src/app/(main)/reports/journey/JourneyParameters.tsx
new file mode 100644
index 00000000..b0544168
--- /dev/null
+++ b/src/app/(main)/reports/journey/JourneyParameters.tsx
@@ -0,0 +1,36 @@
+import { useContext } from 'react';
+import { useMessages } from 'components/hooks';
+import { Form, FormButtons, SubmitButton } from 'react-basics';
+import { ReportContext } from '../[reportId]/Report';
+import BaseParameters from '../[reportId]/BaseParameters';
+
+export function JourneyParameters() {
+ const { report, runReport, isRunning } = useContext(ReportContext);
+ const { formatMessage, labels } = useMessages();
+
+ const { id, parameters } = report || {};
+ const { websiteId, dateRange } = parameters || {};
+ const queryDisabled = !websiteId || !dateRange;
+
+ const handleSubmit = (data: any, e: any) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (!queryDisabled) {
+ runReport(data);
+ }
+ };
+
+ return (
+
+ );
+}
+
+export default JourneyParameters;
diff --git a/src/app/(main)/reports/journey/JourneyReport.tsx b/src/app/(main)/reports/journey/JourneyReport.tsx
new file mode 100644
index 00000000..7b8927b4
--- /dev/null
+++ b/src/app/(main)/reports/journey/JourneyReport.tsx
@@ -0,0 +1,28 @@
+'use client';
+import Report from '../[reportId]/Report';
+import ReportHeader from '../[reportId]/ReportHeader';
+import ReportMenu from '../[reportId]/ReportMenu';
+import ReportBody from '../[reportId]/ReportBody';
+import JourneyParameters from './JourneyParameters';
+import JourneyView from './JourneyView';
+import Path from 'assets/path.svg';
+import { REPORT_TYPES } from 'lib/constants';
+
+const defaultParameters = {
+ type: REPORT_TYPES.journey,
+ parameters: {},
+};
+
+export default function JourneyReport({ reportId }: { reportId?: string }) {
+ return (
+
+ } />
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/reports/journey/JourneyReportPage.tsx b/src/app/(main)/reports/journey/JourneyReportPage.tsx
new file mode 100644
index 00000000..0f4b78ca
--- /dev/null
+++ b/src/app/(main)/reports/journey/JourneyReportPage.tsx
@@ -0,0 +1,5 @@
+import JourneyReport from './JourneyReport';
+
+export default function JourneyReportPage() {
+ return ;
+}
diff --git a/src/app/(main)/reports/journey/JourneyView.module.css b/src/app/(main)/reports/journey/JourneyView.module.css
new file mode 100644
index 00000000..fa7cc0b4
--- /dev/null
+++ b/src/app/(main)/reports/journey/JourneyView.module.css
@@ -0,0 +1,14 @@
+.title {
+ font-size: 24px;
+ line-height: 36px;
+ font-weight: 700;
+}
+
+.row {
+ display: grid;
+ grid-template-columns: 50% 50%;
+ gap: 20px;
+ border-bottom: 1px solid var(--base300);
+ padding-bottom: 30px;
+ margin-bottom: 30px;
+}
diff --git a/src/app/(main)/reports/journey/JourneyView.tsx b/src/app/(main)/reports/journey/JourneyView.tsx
new file mode 100644
index 00000000..6905d74c
--- /dev/null
+++ b/src/app/(main)/reports/journey/JourneyView.tsx
@@ -0,0 +1,13 @@
+import { useContext } from 'react';
+import { ReportContext } from '../[reportId]/Report';
+
+export default function JourneyView() {
+ const { report } = useContext(ReportContext);
+ const { data } = report || {};
+
+ if (!data) {
+ return null;
+ }
+
+ return {JSON.stringify(data)}
;
+}
diff --git a/src/app/(main)/reports/journey/page.tsx b/src/app/(main)/reports/journey/page.tsx
new file mode 100644
index 00000000..447747cc
--- /dev/null
+++ b/src/app/(main)/reports/journey/page.tsx
@@ -0,0 +1,10 @@
+import { Metadata } from 'next';
+import JourneyReportPage from './JourneyReportPage';
+
+export default function () {
+ return ;
+}
+
+export const metadata: Metadata = {
+ title: 'Journey Report',
+};
diff --git a/src/app/(main)/reports/utm/UTMView.tsx b/src/app/(main)/reports/utm/UTMView.tsx
index e59b60eb..f10a68d8 100644
--- a/src/app/(main)/reports/utm/UTMView.tsx
+++ b/src/app/(main)/reports/utm/UTMView.tsx
@@ -34,6 +34,7 @@ export default function UTMView() {
{
data: items.map(({ value }) => value),
backgroundColor: CHART_COLORS,
+ borderWidth: 0,
},
],
};
diff --git a/src/app/(main)/settings/websites/[websiteId]/WebsiteSettings.tsx b/src/app/(main)/settings/websites/[websiteId]/WebsiteSettings.tsx
index 1a92f1f1..11f662b1 100644
--- a/src/app/(main)/settings/websites/[websiteId]/WebsiteSettings.tsx
+++ b/src/app/(main)/settings/websites/[websiteId]/WebsiteSettings.tsx
@@ -61,7 +61,7 @@ export function WebsiteSettings({
{tab === 'details' && }
{tab === 'tracking' && }
- {tab === 'share' && }
+ {tab === 'share' && }
{tab === 'data' && }
>
);
diff --git a/src/assets/path.svg b/src/assets/path.svg
new file mode 100644
index 00000000..29501565
--- /dev/null
+++ b/src/assets/path.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/charts/Chart.tsx b/src/components/charts/Chart.tsx
index 40829cac..d5d22874 100644
--- a/src/components/charts/Chart.tsx
+++ b/src/components/charts/Chart.tsx
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useMemo, ReactNode } from 'react';
import { Loading } from 'react-basics';
import classNames from 'classnames';
-import ChartJS, { LegendItem } from 'chart.js/auto';
+import ChartJS, { LegendItem, ChartOptions } from 'chart.js/auto';
import HoverTooltip from 'components/common/HoverTooltip';
import Legend from 'components/metrics/Legend';
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
@@ -17,7 +17,7 @@ export interface ChartProps {
onUpdate?: (chart: any) => void;
onTooltip?: (model: any) => void;
className?: string;
- chartOptions?: { [key: string]: any };
+ chartOptions?: ChartOptions;
tooltip?: ReactNode;
}
diff --git a/src/components/messages.ts b/src/components/messages.ts
index 1413549f..238ebf52 100644
--- a/src/components/messages.ts
+++ b/src/components/messages.ts
@@ -239,9 +239,14 @@ export const labels = defineMessages({
goals: { id: 'label.goals', defaultMessage: 'Goals' },
goalsDescription: {
id: 'label.goals-description',
- defaultMessage: 'Track your goals for pageviews or events.',
+ defaultMessage: 'Track your goals for pageviews and events.',
},
count: { id: 'label.count', defaultMessage: 'Count' },
+ journey: { id: 'label.journey', defaultMessage: 'Journey' },
+ journeyDescription: {
+ id: 'label.journey-description',
+ defaultMessage: 'Understand how users nagivate through your website.',
+ },
});
export const messages = defineMessages({
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 697a4836..03862660 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -115,6 +115,7 @@ export const REPORT_TYPES = {
insights: 'insights',
retention: 'retention',
utm: 'utm',
+ journey: 'journey',
} as const;
export const REPORT_PARAMETERS = {
diff --git a/src/pages/api/reports/[reportId].ts b/src/pages/api/reports/[reportId].ts
index be2db82f..3a7c4c53 100644
--- a/src/pages/api/reports/[reportId].ts
+++ b/src/pages/api/reports/[reportId].ts
@@ -27,7 +27,7 @@ const schema: YupRequest = {
websiteId: yup.string().uuid().required(),
type: yup
.string()
- .matches(/funnel|insights|retention|utm|goals/i)
+ .matches(/funnel|insights|retention|utm|goals|journey/i)
.required(),
name: yup.string().max(200).required(),
description: yup.string().max(500),
diff --git a/src/pages/api/reports/journey.ts b/src/pages/api/reports/journey.ts
new file mode 100644
index 00000000..84246f05
--- /dev/null
+++ b/src/pages/api/reports/journey.ts
@@ -0,0 +1,54 @@
+import { canViewWebsite } from 'lib/auth';
+import { useAuth, useCors, useValidate } from 'lib/middleware';
+import { NextApiRequestQueryBody } from 'lib/types';
+import { NextApiResponse } from 'next';
+import { methodNotAllowed, ok, unauthorized } from 'next-basics';
+import { getJourney } from 'queries';
+import * as yup from 'yup';
+
+export interface RetentionRequestBody {
+ websiteId: string;
+ dateRange: { startDate: string; endDate: string };
+}
+
+const schema = {
+ POST: yup.object().shape({
+ websiteId: yup.string().uuid().required(),
+ dateRange: yup
+ .object()
+ .shape({
+ startDate: yup.date().required(),
+ endDate: yup.date().required(),
+ })
+ .required(),
+ }),
+};
+
+export default async (
+ req: NextApiRequestQueryBody,
+ res: NextApiResponse,
+) => {
+ await useCors(req, res);
+ await useAuth(req, res);
+ await useValidate(schema, req, res);
+
+ if (req.method === 'POST') {
+ const {
+ websiteId,
+ dateRange: { startDate, endDate },
+ } = req.body;
+
+ if (!(await canViewWebsite(req.auth, websiteId))) {
+ return unauthorized(res);
+ }
+
+ const data = await getJourney(websiteId, {
+ startDate: new Date(startDate),
+ endDate: new Date(endDate),
+ });
+
+ return ok(res, data);
+ }
+
+ return methodNotAllowed(res);
+};
diff --git a/src/queries/analytics/reports/getJourney.ts b/src/queries/analytics/reports/getJourney.ts
new file mode 100644
index 00000000..088f7ee8
--- /dev/null
+++ b/src/queries/analytics/reports/getJourney.ts
@@ -0,0 +1,148 @@
+import clickhouse from 'lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
+import prisma from 'lib/prisma';
+
+export async function getJourney(
+ ...args: [
+ websiteId: string,
+ filters: {
+ startDate: Date;
+ endDate: Date;
+ },
+ ]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ filters: {
+ startDate: Date;
+ endDate: Date;
+ },
+): Promise<
+ {
+ e1: string;
+ e2: string;
+ e3: string;
+ e4: string;
+ e5: string;
+ count: string;
+ }[]
+> {
+ const { startDate, endDate } = filters;
+ const { rawQuery } = prisma;
+
+ return rawQuery(
+ `
+ WITH events AS (
+ select distinct
+ session_id,
+ referrer_path,
+ COALESCE(event_name, url_path) event,
+ ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at) AS event_number
+ from website_event
+ where website_id = {{websiteId::uuid}}
+ and created_at between {{startDate}} and {{endDate}}
+ and referrer_path != url_path),
+ sequences as (
+ SELECT s.e1,
+ s.e2,
+ s.e3,
+ s.e4,
+ s.e5,
+ count(*) count
+ FROM (
+ SELECT session_id,
+ MAX(CASE WHEN event_number = 1 THEN event ELSE NULL END) AS e1,
+ MAX(CASE WHEN event_number = 2 THEN event ELSE NULL END) AS e2,
+ MAX(CASE WHEN event_number = 3 THEN event ELSE NULL END) AS e3,
+ MAX(CASE WHEN event_number = 4 THEN event ELSE NULL END) AS e4,
+ MAX(CASE WHEN event_number = 5 THEN event ELSE NULL END) AS e5
+ FROM events
+ group by session_id) s
+ group by s.e1,
+ s.e2,
+ s.e3,
+ s.e4,
+ s.e5)
+ select *
+ from sequences
+ order by count desc
+ limit 100
+ `,
+ {
+ websiteId,
+ startDate,
+ endDate,
+ },
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ filters: {
+ startDate: Date;
+ endDate: Date;
+ },
+): Promise<
+ {
+ e1: string;
+ e2: string;
+ e3: string;
+ e4: string;
+ e5: string;
+ count: string;
+ }[]
+> {
+ const { startDate, endDate } = filters;
+ const { rawQuery } = clickhouse;
+
+ return rawQuery(
+ `
+ WITH events AS (
+ select distinct
+ session_id,
+ referrer_path,
+ coalesce(nullIf(event_name, ''), url_path) event,
+ row_number() OVER (PARTITION BY session_id ORDER BY created_at) AS event_number
+ from umami.website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and referrer_path != url_path),
+ sequences as (
+ SELECT s.e1,
+ s.e2,
+ s.e3,
+ s.e4,
+ s.e5,
+ count(*) count
+ FROM (
+ SELECT session_id,
+ max(CASE WHEN event_number = 1 THEN event ELSE NULL END) AS e1,
+ max(CASE WHEN event_number = 2 THEN event ELSE NULL END) AS e2,
+ max(CASE WHEN event_number = 3 THEN event ELSE NULL END) AS e3,
+ max(CASE WHEN event_number = 4 THEN event ELSE NULL END) AS e4,
+ max(CASE WHEN event_number = 5 THEN event ELSE NULL END) AS e5
+ FROM events
+ group by session_id) s
+ group by s.e1,
+ s.e2,
+ s.e3,
+ s.e4,
+ s.e5)
+ select *
+ from sequences
+ order by count desc
+ limit 100
+ `,
+ {
+ websiteId,
+ startDate,
+ endDate,
+ },
+ );
+}
diff --git a/src/queries/index.ts b/src/queries/index.ts
index f0002881..8cef080a 100644
--- a/src/queries/index.ts
+++ b/src/queries/index.ts
@@ -12,6 +12,7 @@ export * from './analytics/eventData/getEventDataStats';
export * from './analytics/eventData/getEventDataUsage';
export * from './analytics/events/saveEvent';
export * from './analytics/reports/getFunnel';
+export * from './analytics/reports/getJourney';
export * from './analytics/reports/getRetention';
export * from './analytics/reports/getInsights';
export * from './analytics/reports/getUTM';