diff --git a/db/clickhouse/migrations/05_add_utm_clid.sql b/db/clickhouse/migrations/05_add_utm_clid.sql new file mode 100644 index 00000000..85df4636 --- /dev/null +++ b/db/clickhouse/migrations/05_add_utm_clid.sql @@ -0,0 +1,303 @@ +-- Create Event +CREATE TABLE umami.website_event_new +( + website_id UUID, + session_id UUID, + visit_id UUID, + event_id UUID, + --sessions + hostname LowCardinality(String), + browser LowCardinality(String), + os LowCardinality(String), + device LowCardinality(String), + screen LowCardinality(String), + language LowCardinality(String), + country LowCardinality(String), + subdivision1 LowCardinality(String), + subdivision2 LowCardinality(String), + city String, + --pageviews + url_path String, + url_query String, + utm_source String, + utm_medium String, + utm_campaign String, + utm_content String, + utm_term String, + referrer_path String, + referrer_query String, + referrer_domain String, + page_title String, + gclid String, + fbclid String, + --events + event_type UInt32, + event_name String, + tag String, + created_at DateTime('UTC'), + job_id Nullable(UUID) +) +ENGINE = MergeTree + PARTITION BY toYYYYMM(created_at) + ORDER BY (toStartOfHour(created_at), website_id, session_id, visit_id, created_at) + PRIMARY KEY (toStartOfHour(created_at), website_id, session_id, visit_id) + SETTINGS index_granularity = 8192; + +-- stats hourly +CREATE TABLE umami.website_event_stats_hourly_new +( + website_id UUID, + session_id UUID, + visit_id UUID, + hostname LowCardinality(String), + browser LowCardinality(String), + os LowCardinality(String), + device LowCardinality(String), + screen LowCardinality(String), + language LowCardinality(String), + country LowCardinality(String), + subdivision1 LowCardinality(String), + city String, + entry_url AggregateFunction(argMin, String, DateTime('UTC')), + exit_url AggregateFunction(argMax, String, DateTime('UTC')), + url_path SimpleAggregateFunction(groupArrayArray, Array(String)), + url_query SimpleAggregateFunction(groupArrayArray, Array(String)), + utm_source SimpleAggregateFunction(groupArrayArray, Array(String)), + utm_medium SimpleAggregateFunction(groupArrayArray, Array(String)), + utm_campaign SimpleAggregateFunction(groupArrayArray, Array(String)), + utm_content SimpleAggregateFunction(groupArrayArray, Array(String)), + utm_term SimpleAggregateFunction(groupArrayArray, Array(String)), + referrer_domain SimpleAggregateFunction(groupArrayArray, Array(String)), + page_title SimpleAggregateFunction(groupArrayArray, Array(String)), + gclid SimpleAggregateFunction(groupArrayArray, Array(String)), + fbclid SimpleAggregateFunction(groupArrayArray, Array(String)), + event_type UInt32, + event_name SimpleAggregateFunction(groupArrayArray, Array(String)), + views SimpleAggregateFunction(sum, UInt64), + min_time SimpleAggregateFunction(min, DateTime('UTC')), + max_time SimpleAggregateFunction(max, DateTime('UTC')), + tag SimpleAggregateFunction(groupArrayArray, Array(String)), + created_at Datetime('UTC') +) +ENGINE = AggregatingMergeTree + PARTITION BY toYYYYMM(created_at) + ORDER BY ( + website_id, + event_type, + toStartOfHour(created_at), + cityHash64(visit_id), + visit_id + ) + SAMPLE BY cityHash64(visit_id); + +CREATE MATERIALIZED VIEW umami.website_event_stats_hourly_mv_new +TO umami.website_event_stats_hourly_new +AS +SELECT + website_id, + session_id, + visit_id, + hostname, + browser, + os, + device, + screen, + language, + country, + subdivision1, + city, + entry_url, + exit_url, + url_paths as url_path, + url_query, + utm_source, + utm_medium, + utm_campaign, + utm_content, + utm_term, + referrer_domain, + page_title, + gclid, + fbclid, + event_type, + event_name, + views, + min_time, + max_time, + tag, + timestamp as created_at +FROM (SELECT + website_id, + session_id, + visit_id, + hostname, + browser, + os, + device, + screen, + language, + country, + subdivision1, + city, + argMinState(url_path, created_at) entry_url, + argMaxState(url_path, created_at) exit_url, + arrayFilter(x -> x != '', groupArray(url_path)) as url_paths, + arrayFilter(x -> x != '', groupArray(url_query)) url_query, + arrayFilter(x -> x != '', groupArray(utm_source)) utm_source, + arrayFilter(x -> x != '', groupArray(utm_medium)) utm_medium, + arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign, + arrayFilter(x -> x != '', groupArray(utm_content)) utm_content, + arrayFilter(x -> x != '', groupArray(utm_term)) utm_term, + arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain, + arrayFilter(x -> x != '', groupArray(page_title)) page_title, + arrayFilter(x -> x != '', groupArray(gclid)) gclid, + arrayFilter(x -> x != '', groupArray(fbclid)) fbclid, + event_type, + if(event_type = 2, groupArray(event_name), []) event_name, + sumIf(1, event_type = 1) views, + min(created_at) min_time, + max(created_at) max_time, + arrayFilter(x -> x != '', groupArray(tag)) tag, + toStartOfHour(created_at) timestamp +FROM umami.website_event_new +GROUP BY website_id, + session_id, + visit_id, + hostname, + browser, + os, + device, + screen, + language, + country, + subdivision1, + city, + event_type, + timestamp); + +-- projections +ALTER TABLE umami.website_event_new +ADD PROJECTION website_event_url_path_projection ( +SELECT * ORDER BY toStartOfDay(created_at), website_id, url_path, created_at +); + +ALTER TABLE umami.website_event_new MATERIALIZE PROJECTION website_event_url_path_projection_new; + +ALTER TABLE umami.website_event_new +ADD PROJECTION website_event_referrer_domain_projection ( +SELECT * ORDER BY toStartOfDay(created_at), website_id, referrer_domain, created_at +); + +ALTER TABLE umami.website_event_new MATERIALIZE PROJECTION website_event_referrer_domain_projection; + +-- migration +INSERT INTO umami.website_event_new +SELECT website_id, session_id, visit_id, event_id, hostname, browser, os, device, screen, language, country, subdivision1, subdivision2, city, url_path, url_query, + extract(url_query, 'utm_source=([^&]*)') AS utm_source, + extract(url_query, 'utm_medium=([^&]*)') AS utm_medium, + extract(url_query, 'utm_campaign=([^&]*)') AS utm_campaign, + extract(url_query, 'utm_content=([^&]*)') AS utm_content, + extract(url_query, 'utm_term=([^&]*)') AS utm_term,referrer_path, referrer_query, referrer_domain, + page_title, + extract(url_query, 'gclid=([^&]*)') gclid, + extract(url_query, 'fbclid=([^&]*)') fbclid, + event_type, event_name, tag, created_at, job_id +FROM umami.website_event + +-- rename tables +RENAME TABLE umami.website_event TO umami.website_event_old; +RENAME TABLE umami.website_event_new TO umami.website_event; + +RENAME TABLE umami.website_event_stats_hourly TO umami.website_event_stats_hourly_old; +RENAME TABLE umami.website_event_stats_hourly_new TO umami.website_event_stats_hourly; + +RENAME TABLE umami.website_event_stats_hourly_mv TO umami.website_event_stats_hourly_mv_old; +RENAME TABLE umami.website_event_stats_hourly_mv_new TO umami.website_event_stats_hourly_mv; + +-- recreate view +DROP TABLE umami.website_event_stats_hourly_mv; + +CREATE MATERIALIZED VIEW umami.website_event_stats_hourly_mv +TO umami.website_event_stats_hourly +AS +SELECT + website_id, + session_id, + visit_id, + hostname, + browser, + os, + device, + screen, + language, + country, + subdivision1, + city, + entry_url, + exit_url, + url_paths as url_path, + url_query, + utm_source, + utm_medium, + utm_campaign, + utm_content, + utm_term, + referrer_domain, + page_title, + gclid, + fbclid, + event_type, + event_name, + views, + min_time, + max_time, + tag, + timestamp as created_at +FROM (SELECT + website_id, + session_id, + visit_id, + hostname, + browser, + os, + device, + screen, + language, + country, + subdivision1, + city, + argMinState(url_path, created_at) entry_url, + argMaxState(url_path, created_at) exit_url, + arrayFilter(x -> x != '', groupArray(url_path)) as url_paths, + arrayFilter(x -> x != '', groupArray(url_query)) url_query, + arrayFilter(x -> x != '', groupArray(utm_source)) utm_source, + arrayFilter(x -> x != '', groupArray(utm_medium)) utm_medium, + arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign, + arrayFilter(x -> x != '', groupArray(utm_content)) utm_content, + arrayFilter(x -> x != '', groupArray(utm_term)) utm_term, + arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain, + arrayFilter(x -> x != '', groupArray(page_title)) page_title, + arrayFilter(x -> x != '', groupArray(gclid)) gclid, + arrayFilter(x -> x != '', groupArray(fbclid)) fbclid, + event_type, + if(event_type = 2, groupArray(event_name), []) event_name, + sumIf(1, event_type = 1) views, + min(created_at) min_time, + max(created_at) max_time, + arrayFilter(x -> x != '', groupArray(tag)) tag, + toStartOfHour(created_at) timestamp +FROM umami.website_event +GROUP BY website_id, + session_id, + visit_id, + hostname, + browser, + os, + device, + screen, + language, + country, + subdivision1, + city, + event_type, + timestamp); \ No newline at end of file diff --git a/db/clickhouse/schema.sql b/db/clickhouse/schema.sql index 5ceaaa0e..773319f5 100644 --- a/db/clickhouse/schema.sql +++ b/db/clickhouse/schema.sql @@ -19,10 +19,17 @@ CREATE TABLE umami.website_event --pageviews url_path String, url_query String, + utm_source String, + utm_medium String, + utm_campaign String, + utm_content String, + utm_term String, referrer_path String, referrer_query String, referrer_domain String, page_title String, + gclid String, + fbclid String, --events event_type UInt32, event_name String, @@ -90,8 +97,15 @@ CREATE TABLE umami.website_event_stats_hourly exit_url AggregateFunction(argMax, String, DateTime('UTC')), url_path SimpleAggregateFunction(groupArrayArray, Array(String)), url_query SimpleAggregateFunction(groupArrayArray, Array(String)), + utm_source SimpleAggregateFunction(groupArrayArray, Array(String)), + utm_medium SimpleAggregateFunction(groupArrayArray, Array(String)), + utm_campaign SimpleAggregateFunction(groupArrayArray, Array(String)), + utm_content SimpleAggregateFunction(groupArrayArray, Array(String)), + utm_term SimpleAggregateFunction(groupArrayArray, Array(String)), referrer_domain SimpleAggregateFunction(groupArrayArray, Array(String)), page_title SimpleAggregateFunction(groupArrayArray, Array(String)), + gclid SimpleAggregateFunction(groupArrayArray, Array(String)), + fbclid SimpleAggregateFunction(groupArrayArray, Array(String)), event_type UInt32, event_name SimpleAggregateFunction(groupArrayArray, Array(String)), views SimpleAggregateFunction(sum, UInt64), @@ -131,8 +145,15 @@ SELECT exit_url, url_paths as url_path, url_query, + utm_source, + utm_medium, + utm_campaign, + utm_content, + utm_term, referrer_domain, page_title, + gclid, + fbclid, event_type, event_name, views, @@ -157,8 +178,15 @@ FROM (SELECT argMaxState(url_path, created_at) exit_url, arrayFilter(x -> x != '', groupArray(url_path)) as url_paths, arrayFilter(x -> x != '', groupArray(url_query)) url_query, + arrayFilter(x -> x != '', groupArray(utm_source)) utm_source, + arrayFilter(x -> x != '', groupArray(utm_medium)) utm_medium, + arrayFilter(x -> x != '', groupArray(utm_campaign)) utm_campaign, + arrayFilter(x -> x != '', groupArray(utm_content)) utm_content, + arrayFilter(x -> x != '', groupArray(utm_term)) utm_term, arrayFilter(x -> x != '', groupArray(referrer_domain)) referrer_domain, arrayFilter(x -> x != '', groupArray(page_title)) page_title, + arrayFilter(x -> x != '', groupArray(gclid)) gclid, + arrayFilter(x -> x != '', groupArray(fbclid)) fbclid, event_type, if(event_type = 2, groupArray(event_name), []) event_name, sumIf(1, event_type = 1) views, diff --git a/src/app/(main)/reports/attribution/AttributionParameters.module.css b/src/app/(main)/reports/attribution/AttributionParameters.module.css new file mode 100644 index 00000000..0f27d515 --- /dev/null +++ b/src/app/(main)/reports/attribution/AttributionParameters.module.css @@ -0,0 +1,12 @@ +.item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; +} + +.value { + display: flex; + align-self: center; + gap: 20px; +} diff --git a/src/app/(main)/reports/attribution/AttributionParameters.tsx b/src/app/(main)/reports/attribution/AttributionParameters.tsx new file mode 100644 index 00000000..f9f2915d --- /dev/null +++ b/src/app/(main)/reports/attribution/AttributionParameters.tsx @@ -0,0 +1,125 @@ +import { useMessages } from '@/components/hooks'; +import { useContext, useState } from 'react'; +import { Dropdown, Form, FormButtons, FormInput, FormRow, Item, SubmitButton } from 'react-basics'; +import BaseParameters from '../[reportId]/BaseParameters'; +import { ReportContext } from '../[reportId]/Report'; + +export function AttributionParameters() { + const [model, setModel] = useState('firstClick'); + const { report, runReport, isRunning } = useContext(ReportContext); + const { formatMessage, labels } = useMessages(); + const { id, parameters } = report || {}; + const { websiteId, dateRange } = parameters || {}; + const queryEnabled = websiteId && dateRange; + + 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 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 AddStepButton = () => { + // return ( + // + // + // + // + // + // + // + // + // ); + // }; + + const attributionModel = [ + { label: 'First-Click', value: 'firstClick' }, + { label: 'Last-Click', value: 'lastClick' }, + ]; + + const renderModelValue = (value: any) => { + return attributionModel.find(item => item.value === value)?.label; + }; + + return ( +
+ + + + setModel(value)} + items={attributionModel} + > + {({ value, label }) => { + return {label}; + }} + + + + {/* }> + + {steps.map((step: { type: string; value: string }, index: number) => { + return ( + + : } + onRemove={() => handleRemoveStep(index)} + > +
+
{step.value}
+
+
+ + {(close: () => void) => ( + + + + )} + +
+ ); + })} +
+
*/} + + + {formatMessage(labels.runQuery)} + + + + ); +} + +export default AttributionParameters; diff --git a/src/app/(main)/reports/attribution/AttributionReport.tsx b/src/app/(main)/reports/attribution/AttributionReport.tsx new file mode 100644 index 00000000..90b0b536 --- /dev/null +++ b/src/app/(main)/reports/attribution/AttributionReport.tsx @@ -0,0 +1,27 @@ +import Money from '@/assets/money.svg'; +import { REPORT_TYPES } from '@/lib/constants'; +import Report from '../[reportId]/Report'; +import ReportBody from '../[reportId]/ReportBody'; +import ReportHeader from '../[reportId]/ReportHeader'; +import ReportMenu from '../[reportId]/ReportMenu'; +import AttributionParameters from './AttributionParameters'; +import AttributionView from './AttributionView'; + +const defaultParameters = { + type: REPORT_TYPES.attribution, + parameters: {}, +}; + +export default function AttributionReport({ reportId }: { reportId?: string }) { + return ( + + } /> + + + + + + + + ); +} diff --git a/src/app/(main)/reports/attribution/AttributionReportPage.tsx b/src/app/(main)/reports/attribution/AttributionReportPage.tsx new file mode 100644 index 00000000..ed730704 --- /dev/null +++ b/src/app/(main)/reports/attribution/AttributionReportPage.tsx @@ -0,0 +1,6 @@ +'use client'; +import AttributionReport from './AttributionReport'; + +export default function AttributionReportPage() { + return ; +} diff --git a/src/app/(main)/reports/attribution/AttributionStepAddForm.module.css b/src/app/(main)/reports/attribution/AttributionStepAddForm.module.css new file mode 100644 index 00000000..a254ff08 --- /dev/null +++ b/src/app/(main)/reports/attribution/AttributionStepAddForm.module.css @@ -0,0 +1,7 @@ +.dropdown { + width: 140px; +} + +.input { + width: 200px; +} diff --git a/src/app/(main)/reports/attribution/AttributionStepAddForm.tsx b/src/app/(main)/reports/attribution/AttributionStepAddForm.tsx new file mode 100644 index 00000000..d36b0591 --- /dev/null +++ b/src/app/(main)/reports/attribution/AttributionStepAddForm.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react'; +import { useMessages } from '@/components/hooks'; +import { Button, FormRow, TextField, Flexbox, Dropdown, Item } from 'react-basics'; +import styles from './AttributionStepAddForm.module.css'; + +export interface AttributionStepAddFormProps { + type?: string; + value?: string; + onChange?: (step: { type: string; value: string }) => void; +} + +export function AttributionStepAddForm({ + type: defaultType = 'url', + value: defaultValue = '', + onChange, +}: AttributionStepAddFormProps) { + const [type, setType] = useState(defaultType); + const [value, setValue] = useState(defaultValue); + const { formatMessage, labels } = useMessages(); + const items = [ + { label: formatMessage(labels.url), value: 'url' }, + { label: formatMessage(labels.event), value: 'event' }, + ]; + const isDisabled = !type || !value; + + const handleSave = () => { + onChange({ type, value }); + setValue(''); + }; + + const handleChange = e => { + setValue(e.target.value); + }; + + const handleKeyDown = e => { + if (e.key === 'Enter') { + e.stopPropagation(); + handleSave(); + } + }; + + const renderTypeValue = (value: any) => { + return items.find(item => item.value === value)?.label; + }; + + return ( + + + + setType(value)} + > + {({ value, label }) => { + return {label}; + }} + + + + + + + + + ); +} + +export default AttributionStepAddForm; diff --git a/src/app/(main)/reports/attribution/AttributionTable.tsx b/src/app/(main)/reports/attribution/AttributionTable.tsx new file mode 100644 index 00000000..84d73b28 --- /dev/null +++ b/src/app/(main)/reports/attribution/AttributionTable.tsx @@ -0,0 +1,38 @@ +import EmptyPlaceholder from '@/components/common/EmptyPlaceholder'; +import { useMessages } from '@/components/hooks'; +import { useContext } from 'react'; +import { GridColumn, GridTable } from 'react-basics'; +import { ReportContext } from '../[reportId]/Report'; +import { formatLongCurrency } from '@/lib/format'; + +export function AttributionTable() { + const { report } = useContext(ReportContext); + const { formatMessage, labels } = useMessages(); + const { data } = report || {}; + + if (!data) { + return ; + } + + return ( + + + {row => row.currency} + + + {row => formatLongCurrency(row.sum, row.currency)} + + + {row => formatLongCurrency(row.count ? row.sum / row.count : 0, row.currency)} + + + {row => row.count} + + + {row => row.unique_count} + + + ); +} + +export default AttributionTable; diff --git a/src/app/(main)/reports/attribution/AttributionView.module.css b/src/app/(main)/reports/attribution/AttributionView.module.css new file mode 100644 index 00000000..9b35260e --- /dev/null +++ b/src/app/(main)/reports/attribution/AttributionView.module.css @@ -0,0 +1,11 @@ +.container { + display: grid; + gap: 20px; + margin-bottom: 40px; +} + +.row { + display: flex; + align-items: center; + gap: 10px; +} diff --git a/src/app/(main)/reports/attribution/AttributionView.tsx b/src/app/(main)/reports/attribution/AttributionView.tsx new file mode 100644 index 00000000..2b6802e1 --- /dev/null +++ b/src/app/(main)/reports/attribution/AttributionView.tsx @@ -0,0 +1,156 @@ +import classNames from 'classnames'; +import { colord } from 'colord'; +import BarChart from '@/components/charts/BarChart'; +import PieChart from '@/components/charts/PieChart'; +import TypeIcon from '@/components/common/TypeIcon'; +import { useCountryNames, useLocale, useMessages } from '@/components/hooks'; +import { GridRow } from '@/components/layout/Grid'; +import ListTable from '@/components/metrics/ListTable'; +import MetricCard from '@/components/metrics/MetricCard'; +import MetricsBar from '@/components/metrics/MetricsBar'; +import { renderDateLabels } from '@/lib/charts'; +import { CHART_COLORS } from '@/lib/constants'; +import { formatLongCurrency, formatLongNumber } from '@/lib/format'; +import { useCallback, useContext, useMemo } from 'react'; +import { ReportContext } from '../[reportId]/Report'; +import AttributionTable from './AttributionTable'; +import styles from './AttributionView.module.css'; + +export interface AttributionViewProps { + isLoading?: boolean; +} + +export function AttributionView({ isLoading }: AttributionViewProps) { + const { formatMessage, labels } = useMessages(); + const { locale } = useLocale(); + const { countryNames } = useCountryNames(locale); + const { report } = useContext(ReportContext); + const { + data, + parameters: { dateRange, currency }, + } = report || {}; + const showTable = data?.table.length > 1; + + const renderCountryName = useCallback( + ({ x: code }) => ( + + + {countryNames[code]} + + ), + [countryNames, locale], + ); + + const chartData = useMemo(() => { + if (!data) return []; + + const map = (data.chart as any[]).reduce((obj, { x, t, y }) => { + if (!obj[x]) { + obj[x] = []; + } + + obj[x].push({ x: t, y }); + + return obj; + }, {}); + + return { + datasets: Object.keys(map).map((key, index) => { + const color = colord(CHART_COLORS[index % CHART_COLORS.length]); + return { + label: key, + data: map[key], + lineTension: 0, + backgroundColor: color.alpha(0.6).toRgbString(), + borderColor: color.alpha(0.7).toRgbString(), + borderWidth: 1, + }; + }), + }; + }, [data]); + + const countryData = useMemo(() => { + if (!data) return []; + + const labels = data.country.map(({ name }) => name); + const datasets = [ + { + data: data.country.map(({ value }) => value), + backgroundColor: CHART_COLORS, + borderWidth: 0, + }, + ]; + + return { labels, datasets }; + }, [data]); + + const metricData = useMemo(() => { + if (!data) return []; + + const { sum, count, unique_count } = data.total; + + return [ + { + value: sum, + label: formatMessage(labels.total), + formatValue: n => formatLongCurrency(n, currency), + }, + { + value: count ? sum / count : 0, + label: formatMessage(labels.average), + formatValue: n => formatLongCurrency(n, currency), + }, + { + value: count, + label: formatMessage(labels.transactions), + formatValue: formatLongNumber, + }, + { + value: unique_count, + label: formatMessage(labels.uniqueCustomers), + formatValue: formatLongNumber, + }, + ] as any; + }, [data, locale]); + + return ( + <> +
+ + {metricData?.map(({ label, value, formatValue }) => { + return ; + })} + + {data && ( + <> + + + ({ + x: name, + y: Number(value), + z: (value / data?.total.sum) * 100, + }))} + renderLabel={renderCountryName} + /> + + + + )} + {showTable && } +
+ + ); +} + +export default AttributionView; diff --git a/src/app/(main)/reports/attribution/page.tsx b/src/app/(main)/reports/attribution/page.tsx new file mode 100644 index 00000000..9efd6220 --- /dev/null +++ b/src/app/(main)/reports/attribution/page.tsx @@ -0,0 +1,10 @@ +import AttributionReportPage from './AttributionReportPage'; +import { Metadata } from 'next'; + +export default function () { + return ; +} + +export const metadata: Metadata = { + title: 'Attribution Report', +}; diff --git a/src/app/(main)/reports/create/ReportTemplates.tsx b/src/app/(main)/reports/create/ReportTemplates.tsx index c26e3a91..4748b5c9 100644 --- a/src/app/(main)/reports/create/ReportTemplates.tsx +++ b/src/app/(main)/reports/create/ReportTemplates.tsx @@ -58,6 +58,12 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean }) url: renderTeamUrl('/reports/revenue'), icon: , }, + { + title: formatMessage(labels.attribution), + description: formatMessage(labels.attributionDescription), + url: renderTeamUrl('/reports/attribution'), + icon: , + }, ]; return ( diff --git a/src/components/messages.ts b/src/components/messages.ts index 5279e1b4..653b0065 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -163,7 +163,13 @@ export const labels = defineMessages({ id: 'label.revenue-description', defaultMessage: 'Look into your revenue data and how users are spending.', }, + attribution: { id: 'label.attribution', defaultMessage: 'Attribution' }, + attributionDescription: { + id: 'label.attribution-description', + defaultMessage: 'See how users engage with your marketing and what drives conversions.', + }, currency: { id: 'label.currency', defaultMessage: 'Currency' }, + model: { id: 'label.model', defaultMessage: 'Model' }, url: { id: 'label.url', defaultMessage: 'URL' }, urls: { id: 'label.urls', defaultMessage: 'URLs' }, path: { id: 'label.path', defaultMessage: 'Path' }, diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 3eddefdc..07b979aa 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -124,6 +124,7 @@ export const REPORT_TYPES = { utm: 'utm', journey: 'journey', revenue: 'revenue', + attribution: 'attribution', } as const; export const REPORT_PARAMETERS = { diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 4e2b3e4a..1997a54c 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -60,6 +60,7 @@ export const reportTypeParam = z.enum([ 'goals', 'journey', 'revenue', + 'attribution', ]); export const reportParms = {