diff --git a/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx b/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx index 5f4ba943..ce7c569a 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteFilterButton.tsx @@ -36,7 +36,7 @@ export function WebsiteFilterButton({ - {showText && {formatMessage(labels.filter)}} + {showText && {formatMessage(labels.filter)}} diff --git a/src/app/(main)/websites/[websiteId]/reports/attribution/Attribution.tsx b/src/app/(main)/websites/[websiteId]/reports/attribution/Attribution.tsx new file mode 100644 index 00000000..82d8ec85 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/reports/attribution/Attribution.tsx @@ -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('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 ( + ({ + x: name, + y: Number(value), + z: (value / total) * 100, + }))} + /> + ); + } + + return ( + + + + + {metrics?.map(({ label, value, formatValue }) => { + return ( + + ); + })} + + + {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 ( + + {label} + + ({ + x: name, + y: Number(value), + z: (value / total) * 100, + }))} + /> + + + + ); + })} + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/reports/attribution/AttributionPage.tsx b/src/app/(main)/websites/[websiteId]/reports/attribution/AttributionPage.tsx new file mode 100644 index 00000000..f7058ca3 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/reports/attribution/AttributionPage.tsx @@ -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 ( + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/reports/attribution/Goal.tsx b/src/app/(main)/websites/[websiteId]/reports/attribution/Goal.tsx deleted file mode 100644 index 8e2d453a..00000000 --- a/src/app/(main)/websites/[websiteId]/reports/attribution/Goal.tsx +++ /dev/null @@ -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(type, { - websiteId, - dateRange: { - startDate, - endDate, - }, - parameters, - }); - const isPage = parameters?.type === 'page'; - - return ( - - - - - - - {name} - - - - - - {({ close }) => { - return ( - - - - ); - }} - - - - - - {formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)} - - {formatMessage(labels.conversionRate)} - - - - {parameters.type === 'page' ? : } - {parameters.value} - - - - - - {`${formatLongNumber( - data?.num, - )} / ${formatLongNumber(data?.total)}`} - - - - - - {data?.total ? Math.round((+data?.num / +data?.total) * 100) : '0'}% - - - - - ); -} diff --git a/src/app/(main)/websites/[websiteId]/reports/attribution/GoalAddButton.tsx b/src/app/(main)/websites/[websiteId]/reports/attribution/GoalAddButton.tsx deleted file mode 100644 index cdd7d34a..00000000 --- a/src/app/(main)/websites/[websiteId]/reports/attribution/GoalAddButton.tsx +++ /dev/null @@ -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 ( - - - - - {({ close }) => } - - - - ); -} diff --git a/src/app/(main)/websites/[websiteId]/reports/attribution/GoalEditForm.tsx b/src/app/(main)/websites/[websiteId]/reports/attribution/GoalEditForm.tsx deleted file mode 100644 index b42545de..00000000 --- a/src/app/(main)/websites/[websiteId]/reports/attribution/GoalEditForm.tsx +++ /dev/null @@ -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 ; - } - - const defaultValues = { - name: data?.name || '', - type: data?.parameters?.type || 'page', - value: data?.parameters?.value || '', - }; - - return ( -
- {({ watch }) => { - const watchType = watch('type'); - return ( - <> - - - - - - - - - - - {formatMessage(labels.page)} - - - - - - {formatMessage(labels.event)} - - - - - - - - - - {formatMessage(labels.save)} - - - ); - }} -
- ); -} diff --git a/src/app/(main)/websites/[websiteId]/reports/attribution/GoalsPage.tsx b/src/app/(main)/websites/[websiteId]/reports/attribution/GoalsPage.tsx deleted file mode 100644 index 522d984d..00000000 --- a/src/app/(main)/websites/[websiteId]/reports/attribution/GoalsPage.tsx +++ /dev/null @@ -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 ; - } - - return ( - <> - - - - - - - {result?.data?.map((report: any) => ( - - - - ))} - - - - ); -} diff --git a/src/app/(main)/websites/[websiteId]/reports/attribution/page.tsx b/src/app/(main)/websites/[websiteId]/reports/attribution/page.tsx index e59b2c7c..ec75c58c 100644 --- a/src/app/(main)/websites/[websiteId]/reports/attribution/page.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/attribution/page.tsx @@ -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 ; + return ; } export const metadata: Metadata = { - title: 'Goals', + title: 'Attribution', }; diff --git a/src/app/(main)/websites/[websiteId]/reports/funnels/FunnelsPage.tsx b/src/app/(main)/websites/[websiteId]/reports/funnels/FunnelsPage.tsx index d887e8e0..4c7e831b 100644 --- a/src/app/(main)/websites/[websiteId]/reports/funnels/FunnelsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/funnels/FunnelsPage.tsx @@ -17,10 +17,10 @@ export function FunnelsPage({ websiteId }: { websiteId: string }) { return ( - - - - + + + + {result?.data?.map((report: any) => ( diff --git a/src/app/(main)/websites/[websiteId]/reports/goals/GoalsPage.tsx b/src/app/(main)/websites/[websiteId]/reports/goals/GoalsPage.tsx index 3ee32f15..0829e390 100644 --- a/src/app/(main)/websites/[websiteId]/reports/goals/GoalsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/goals/GoalsPage.tsx @@ -17,10 +17,10 @@ export function GoalsPage({ websiteId }: { websiteId: string }) { return ( - - - - + + + + {result?.data?.map((report: any) => ( diff --git a/src/app/(main)/websites/[websiteId]/reports/insights/Goal.tsx b/src/app/(main)/websites/[websiteId]/reports/insights/Goal.tsx deleted file mode 100644 index 8e2d453a..00000000 --- a/src/app/(main)/websites/[websiteId]/reports/insights/Goal.tsx +++ /dev/null @@ -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(type, { - websiteId, - dateRange: { - startDate, - endDate, - }, - parameters, - }); - const isPage = parameters?.type === 'page'; - - return ( - - - - - - - {name} - - - - - - {({ close }) => { - return ( - - - - ); - }} - - - - - - {formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)} - - {formatMessage(labels.conversionRate)} - - - - {parameters.type === 'page' ? : } - {parameters.value} - - - - - - {`${formatLongNumber( - data?.num, - )} / ${formatLongNumber(data?.total)}`} - - - - - - {data?.total ? Math.round((+data?.num / +data?.total) * 100) : '0'}% - - - - - ); -} diff --git a/src/app/(main)/websites/[websiteId]/reports/insights/GoalAddButton.tsx b/src/app/(main)/websites/[websiteId]/reports/insights/GoalAddButton.tsx deleted file mode 100644 index cdd7d34a..00000000 --- a/src/app/(main)/websites/[websiteId]/reports/insights/GoalAddButton.tsx +++ /dev/null @@ -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 ( - - - - - {({ close }) => } - - - - ); -} diff --git a/src/app/(main)/websites/[websiteId]/reports/insights/GoalEditForm.tsx b/src/app/(main)/websites/[websiteId]/reports/insights/GoalEditForm.tsx deleted file mode 100644 index b42545de..00000000 --- a/src/app/(main)/websites/[websiteId]/reports/insights/GoalEditForm.tsx +++ /dev/null @@ -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 ; - } - - const defaultValues = { - name: data?.name || '', - type: data?.parameters?.type || 'page', - value: data?.parameters?.value || '', - }; - - return ( -
- {({ watch }) => { - const watchType = watch('type'); - return ( - <> - - - - - - - - - - - {formatMessage(labels.page)} - - - - - - {formatMessage(labels.event)} - - - - - - - - - - {formatMessage(labels.save)} - - - ); - }} -
- ); -} diff --git a/src/app/(main)/websites/[websiteId]/reports/insights/GoalsPage.tsx b/src/app/(main)/websites/[websiteId]/reports/insights/GoalsPage.tsx deleted file mode 100644 index 522d984d..00000000 --- a/src/app/(main)/websites/[websiteId]/reports/insights/GoalsPage.tsx +++ /dev/null @@ -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 ; - } - - return ( - <> - - - - - - - {result?.data?.map((report: any) => ( - - - - ))} - - - - ); -} diff --git a/src/app/(main)/websites/[websiteId]/reports/insights/Insights.tsx b/src/app/(main)/websites/[websiteId]/reports/insights/Insights.tsx new file mode 100644 index 00000000..d0a084d7 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/reports/insights/Insights.tsx @@ -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('insights', { + websiteId, + dateRange: { + startDate, + endDate, + }, + parameters: { + days, + }, + }); + + if (isLoading) { + return ; + } + + if (!data) { + return ; + } + + 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 ( + + + + + + {formatMessage(labels.cohort)} + + {days.map(n => ( + + + {formatMessage(labels.day)} {n} + + + ))} + + {rows.map(({ date, visitors, records }: any, rowIndex: number) => { + return ( + + + {formatDate(date, 'PP', locale)} + + + + + {formatLongNumber(visitors)} + + + {days.map(day => { + if (totalDays - rowIndex < day) { + return null; + } + const percentage = records.filter(a => a.day === day)[0]?.percentage; + return ( + {percentage ? `${Number(percentage).toFixed(2)}%` : ''} + ); + })} + + ); + })} + + + + ); +} + +const Cell = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/reports/insights/InsightsPage.tsx b/src/app/(main)/websites/[websiteId]/reports/insights/InsightsPage.tsx new file mode 100644 index 00000000..2972a6ef --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/reports/insights/InsightsPage.tsx @@ -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 ( + + + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/reports/insights/page.tsx b/src/app/(main)/websites/[websiteId]/reports/insights/page.tsx index e59b2c7c..a4eee1f2 100644 --- a/src/app/(main)/websites/[websiteId]/reports/insights/page.tsx +++ b/src/app/(main)/websites/[websiteId]/reports/insights/page.tsx @@ -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 ; + return ; } export const metadata: Metadata = { - title: 'Goals', + title: 'Insights', }; diff --git a/src/app/api/reports/attribution/route.ts b/src/app/api/reports/attribution/route.ts index a1f7992d..ef6f8650 100644 --- a/src/app/api/reports/attribution/route.ts +++ b/src/app/api/reports/attribution/route.ts @@ -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, }); diff --git a/src/components/common/Empty.tsx b/src/components/common/Empty.tsx index 6d84b3e6..8a20be3a 100644 --- a/src/components/common/Empty.tsx +++ b/src/components/common/Empty.tsx @@ -9,7 +9,14 @@ export function Empty({ message }: EmptyProps) { const { formatMessage, messages } = useMessages(); return ( - + {message || formatMessage(messages.noDataAvailable)} ); diff --git a/src/components/input/DateFilter.tsx b/src/components/input/DateFilter.tsx index 8f120860..d8ac0b08 100644 --- a/src/components/input/DateFilter.tsx +++ b/src/components/input/DateFilter.tsx @@ -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 ( diff --git a/src/components/messages.ts b/src/components/messages.ts index 2b1b2d7c..e521d0da 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -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({ diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 8361acc8..73d28660 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -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({ diff --git a/src/queries/sql/reports/getAttribution.ts b/src/queries/sql/reports/getAttribution.ts index f224eb5c..7454d1ce 100644 --- a/src/queries/sql/reports/getAttribution.ts +++ b/src/queries/sql/reports/getAttribution.ts @@ -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 {