Merge dev.

This commit is contained in:
Mike Cao 2025-04-28 20:09:58 -07:00
commit be1b2fc272
88 changed files with 4120 additions and 21010 deletions

View file

@ -1,6 +0,0 @@
'use client';
import { TestConsole } from './TestConsole';
export function ConsolePage({ websiteId }) {
return <TestConsole websiteId={websiteId} />;
}

View file

@ -1,5 +1,5 @@
import { Metadata } from 'next';
import { ConsolePage } from '../ConsolePage';
import { TestConsole } from '../TestConsole';
async function getEnabled() {
return !!process.env.ENABLE_TEST_CONSOLE;
@ -14,7 +14,7 @@ export default async function ({ params }: { params: Promise<{ websiteId: string
return null;
}
return <ConsolePage websiteId={websiteId?.[0]} />;
return <TestConsole websiteId={websiteId?.[0]} />;
}
export const metadata: Metadata = {

View file

@ -8,6 +8,7 @@ import { JourneyReport } from '../journey/JourneyReport';
import { RetentionReport } from '../retention/RetentionReport';
import { UTMReport } from '../utm/UTMReport';
import { RevenueReport } from '../revenue/RevenueReport';
import AttributionReport from '../attribution/AttributionReport';
const reports = {
funnel: FunnelReport,
@ -18,6 +19,7 @@ const reports = {
goals: GoalsReport,
journey: JourneyReport,
revenue: RevenueReport,
attribution: AttributionReport,
};
export function ReportPage({ reportId }: { reportId: string }) {

View file

@ -0,0 +1,174 @@
import { useMessages } from '@/components/hooks';
import { Icons } from '@/components/icons';
import { useContext, useState } from 'react';
import {
Button,
Select,
Form,
FormButtons,
FormField,
Icon,
ListItem,
Popover,
DialogTrigger,
Toggle,
FormSubmitButton,
} from '@umami/react-zen';
import { BaseParameters } from '../[reportId]/BaseParameters';
import { ParameterList } from '../[reportId]/ParameterList';
import { ReportContext } from '../[reportId]/Report';
import { FunnelStepAddForm } from '../funnel/FunnelStepAddForm';
import { AttributionStepAddForm } from './AttributionStepAddForm';
import { useRevenueValuesQuery } from '@/components/hooks/queries/useRevenueValuesQuery';
export function AttributionParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {};
const { websiteId, dateRange, steps } = parameters || {};
const queryEnabled = websiteId && dateRange && steps.length > 0;
const [model, setModel] = useState('');
const [revenueMode, setRevenueMode] = useState(false);
const { data: currencyValues = [] } = useRevenueValuesQuery(
websiteId,
dateRange?.startDate,
dateRange?.endDate,
);
const handleSubmit = (data: any, e: any) => {
if (revenueMode === false) {
delete data.currency;
}
e.stopPropagation();
e.preventDefault();
runReport(data);
};
const handleCheck = () => {
setRevenueMode(!revenueMode);
};
const handleAddStep = (step: { type: string; value: string }) => {
if (step.type === 'url') {
setRevenueMode(false);
}
updateReport({ parameters: { steps: parameters.steps.concat(step) } });
};
const handleUpdateStep = (
close: () => void,
index: number,
step: { type: string; value: string },
) => {
if (step.type === 'url') {
setRevenueMode(false);
}
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 (
<DialogTrigger>
<Button isDisabled={steps.length > 0}>
<Icon>
<Icons.Plus />
</Icon>
</Button>
<Popover placement="right top">
<FunnelStepAddForm onChange={handleAddStep} />
</Popover>
</DialogTrigger>
);
};
const items = [
{ id: 'first-click', label: 'First-Click', value: 'firstClick' },
{ id: 'last-click', label: 'Last-Click', value: 'lastClick' },
];
const onModelChange = (value: any) => {
setModel(value);
updateReport({ parameters: { model } });
};
return (
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
<BaseParameters showDateSelect={true} allowWebsiteSelect={!id} />
<FormField
name="model"
rules={{ required: formatMessage(labels.required) }}
label={formatMessage(labels.model)}
>
<Select items={items} value={model} onChange={onModelChange}>
{({ value, label }: any) => {
return <ListItem key={value}>{label}</ListItem>;
}}
</Select>
</FormField>
<FormField name="step" label={formatMessage(labels.conversionStep)}>
<ParameterList>
{steps.map((step: { type: string; value: string }, index: number) => {
return (
<DialogTrigger key={index}>
<ParameterList.Item
icon={step.type === 'url' ? <Icons.Eye /> : <Icons.Bolt />}
onRemove={() => handleRemoveStep(index)}
>
<div>{step.value}</div>
</ParameterList.Item>
<Popover placement="right top">
<AttributionStepAddForm
type={step.type}
value={step.value}
onChange={handleUpdateStep.bind(null, close, index)}
/>
</Popover>
</DialogTrigger>
);
})}
</ParameterList>
<AddStepButton />
</FormField>
<Toggle
isSelected={revenueMode}
onClick={handleCheck}
isDisabled={currencyValues.length === 0 || steps[0]?.type === 'url'}
>
<b>Revenue Mode</b>
</Toggle>
{revenueMode && (
<FormField
name="currency"
rules={{ required: formatMessage(labels.required) }}
label={formatMessage(labels.currency)}
>
<Select items={currencyValues.map(item => ({ id: item.currency, value: item.currency }))}>
{({ id, value }: any) => (
<ListItem key={id} id={id}>
{value}
</ListItem>
)}
</Select>
</FormField>
)}
<FormButtons>
<FormSubmitButton variant="primary" isDisabled={!queryEnabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -0,0 +1,27 @@
import { Icons } from '@/components/icons';
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: { model: 'firstClick', steps: [] },
};
export default function AttributionReport({ reportId }: { reportId?: string }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Icons.Network />} />
<ReportMenu>
<AttributionParameters />
</ReportMenu>
<ReportBody>
<AttributionView />
</ReportBody>
</Report>
);
}

View file

@ -0,0 +1,6 @@
'use client';
import AttributionReport from './AttributionReport';
export default function AttributionReportPage() {
return <AttributionReport />;
}

View file

@ -0,0 +1,77 @@
import { useState } from 'react';
import { useMessages } from '@/components/hooks';
import {
Button,
FormButtons,
FormField,
TextField,
Row,
Column,
Select,
ListItem,
} from '@umami/react-zen';
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 = [
{ id: 'url', label: formatMessage(labels.url), value: 'url' },
{ id: 'event', 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();
}
};
return (
<Column gap>
<FormField name="steps" label={formatMessage(defaultValue ? labels.update : labels.add)}>
<Row>
<Select items={items} value={type} onChange={(value: any) => setType(value)}>
{({ value, label }: any) => {
return <ListItem key={value}>{label}</ListItem>;
}}
</Select>
<TextField
value={value}
onChange={handleChange}
autoFocus={true}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
</Row>
</FormField>
<FormButtons>
<Button variant="primary" onClick={handleSave} isDisabled={isDisabled}>
{formatMessage(defaultValue ? labels.update : labels.add)}
</Button>
</FormButtons>
</Column>
);
}
export default AttributionStepAddForm;

View file

@ -0,0 +1,133 @@
import { useContext } from 'react';
import { PieChart } from '@/components/charts/PieChart';
import { useMessages } from '@/components/hooks';
import { GridRow } from '@/components/common/GridRow';
import { ListTable } from '@/components/metrics/ListTable';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { CHART_COLORS } from '@/lib/constants';
import { formatLongNumber } from '@/lib/format';
import { ReportContext } from '../[reportId]/Report';
export interface AttributionViewProps {
isLoading?: boolean;
}
export function AttributionView({ isLoading }: AttributionViewProps) {
const { formatMessage, labels } = useMessages();
const { report } = useContext(ReportContext);
const {
data,
parameters: { currency },
} = report || {};
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 (
<div>
<MetricsBar isFetched={data}>
{metrics?.map(({ label, value, formatValue }) => {
return <MetricCard key={label} value={value} label={label} formatValue={formatValue} />;
})}
</MetricsBar>
{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 (
<div key={value}>
<div>
<div>{label}</div>
<ListTable
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
currency={currency}
data={items.map(({ name, value }) => ({
x: name,
y: Number(value),
z: (value / total) * 100,
}))}
/>
</div>
<div>
<PieChart type="doughnut" data={chartData} isLoading={isLoading} />
</div>
</div>
);
})}
<>
<GridRow columns="two">
<UTMTable data={data} title={formatMessage(labels.sources)} utm={'utm_source'} />
<UTMTable data={data} title={formatMessage(labels.medium)} utm={'utm_medium'} />
</GridRow>
<GridRow columns="three">
<UTMTable data={data} title={formatMessage(labels.campaigns)} utm={'utm_campaign'} />
<UTMTable data={data} title={formatMessage(labels.content)} utm={'utm_content'} />
<UTMTable data={data} title={formatMessage(labels.terms)} utm={'utm_term'} />
</GridRow>
</>
</div>
);
}
export default AttributionView;

View file

@ -0,0 +1,10 @@
import AttributionReportPage from './AttributionReportPage';
import { Metadata } from 'next';
export default function () {
return <AttributionReportPage />;
}
export const metadata: Metadata = {
title: 'Attribution Report',
};

View file

@ -51,6 +51,12 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean })
url: renderTeamUrl('/reports/revenue'),
icon: <Icons.Money />,
},
{
title: formatMessage(labels.attribution),
description: formatMessage(labels.attributionDescription),
url: renderTeamUrl('/reports/attribution'),
icon: <Icons.Network />,
},
];
return (

View file

@ -27,8 +27,8 @@ export function FunnelStepAddForm({
const [value, setValue] = useState(defaultValue);
const { formatMessage, labels } = useMessages();
const items = [
{ label: formatMessage(labels.url), value: 'url' },
{ label: formatMessage(labels.event), value: 'event' },
{ id: 'url', label: formatMessage(labels.url), value: 'url' },
{ id: 'event', label: formatMessage(labels.event), value: 'event' },
];
const isDisabled = !type || !value;

View file

@ -1,5 +1,6 @@
'use client';
import { TabList, Tab, Tabs, TabPanel, Column } from '@umami/react-zen';
import { EventsTable } from '@/components/metrics/EventsTable';
import { useState } from 'react';
import { WebsiteHeader } from '../WebsiteHeader';
import { EventsDataTable } from './EventsDataTable';
@ -12,9 +13,14 @@ import { useMessages } from '@/components/hooks';
import { EventProperties } from './EventProperties';
export function EventsPage({ websiteId }) {
const [label, setLabel] = useState(null);
const [tab, setTab] = useState('activity');
const { formatMessage, labels } = useMessages();
const handleLabelClick = (value: string) => {
setLabel(value !== label ? value : '');
};
return (
<Column gap="3">
<WebsiteHeader websiteId={websiteId} />
@ -34,6 +40,17 @@ export function EventsPage({ websiteId }) {
/>
</Panel>
</GridRow>
<EventsMetricsBar websiteId={websiteId} />
<GridRow columns="two-one">
<EventsChart websiteId={websiteId} focusLabel={label} />
<EventsTable
websiteId={websiteId}
type="event"
title={formatMessage(labels.events)}
metric={formatMessage(labels.actions)}
onLabelClick={handleLabelClick}
/>
</GridRow>
<Panel marginY="6">
<Tabs selectedKey={tab} onSelectionChange={(value: any) => setTab(value)}>
<TabList>

View file

@ -14,7 +14,7 @@ import { RealtimeCountries } from './RealtimeCountries';
import { WebsiteHeader } from '../WebsiteHeader';
import { percentFilter } from '@/lib/filters';
export function WebsiteRealtimePage({ websiteId }) {
export function WebsiteRealtimePage({ websiteId }: { websiteId: string }) {
const { data, isLoading, error } = useRealtimeQuery(websiteId);
if (isLoading || error) {

View file

@ -35,15 +35,15 @@ export function SessionInfo({ data }) {
</Row>
</Box>
<Box>
<Row>
<Label>{formatMessage(labels.region)}</Label>
<Row gap="3">
<Icon>
<Icons.Location />
</Icon>
<Text>{getRegionName(data?.subdivision1)}</Text>
{getRegionName(data?.region)}
</Row>
</Box>
</Row>
<Box>
<Label>{formatMessage(labels.city)}</Label>

View file

@ -0,0 +1,50 @@
import { canViewWebsite } from '@/lib/auth';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { reportParms } 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);
if (error) {
return error();
}
const {
websiteId,
model,
steps,
currency,
dateRange: { startDate, endDate },
} = body;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const data = await getAttribution(websiteId, {
startDate: new Date(startDate),
endDate: new Date(endDate),
model: model,
steps,
currency,
});
return json(data);
}

View file

@ -29,6 +29,7 @@ const schema = z.object({
ip: z.string().ip().optional(),
userAgent: z.string().optional(),
timestamp: z.coerce.number().int().optional(),
id: z.string().optional(),
}),
});
@ -54,6 +55,7 @@ export async function POST(request: Request) {
title,
tag,
timestamp,
id,
} = payload;
// Cache check
@ -78,8 +80,10 @@ export async function POST(request: Request) {
}
// Client info
const { ip, userAgent, device, browser, os, country, subdivision1, subdivision2, city } =
await getClientInfo(request, payload);
const { ip, userAgent, device, browser, os, country, region, city } = await getClientInfo(
request,
payload,
);
// Bot check
if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) {
@ -97,7 +101,7 @@ export async function POST(request: Request) {
const sessionSalt = hash(startOfMonth(createdAt).toUTCString());
const visitSalt = hash(startOfHour(createdAt).toUTCString());
const sessionId = uuid(websiteId, ip, userAgent, sessionSalt);
const sessionId = id ? uuid(websiteId, id) : uuid(websiteId, ip, userAgent, sessionSalt);
// Find session
if (!clickhouse.enabled && !cache?.sessionId) {
@ -109,15 +113,13 @@ export async function POST(request: Request) {
await createSession({
id: sessionId,
websiteId,
hostname,
browser,
os,
device,
screen,
language,
country,
subdivision1,
subdivision2,
region,
city,
});
} catch (e: any) {
@ -146,14 +148,29 @@ export async function POST(request: Request) {
const urlQuery = currentUrl.search.substring(1);
const urlDomain = currentUrl.hostname.replace(/^www./, '');
if (process.env.REMOVE_TRAILING_SLASH) {
urlPath = urlPath.replace(/(.+)\/$/, '$1');
}
let referrerPath: string;
let referrerQuery: string;
let referrerDomain: string;
// UTM Params
const utmSource = currentUrl.searchParams.get('utm_source');
const utmMedium = currentUrl.searchParams.get('utm_medium');
const utmCampaign = currentUrl.searchParams.get('utm_campaign');
const utmContent = currentUrl.searchParams.get('utm_content');
const utmTerm = currentUrl.searchParams.get('utm_term');
// Click IDs
const gclid = currentUrl.searchParams.get('gclid');
const fbclid = currentUrl.searchParams.get('fbclid');
const msclkid = currentUrl.searchParams.get('msclkid');
const ttclid = currentUrl.searchParams.get('ttclid');
const lifatid = currentUrl.searchParams.get('li_fat_id');
const twclid = currentUrl.searchParams.get('twclid');
if (process.env.REMOVE_TRAILING_SLASH) {
urlPath = urlPath.replace(/(.+)\/$/, '$1');
}
if (referrer) {
const referrerUrl = new URL(referrer, base);
@ -171,10 +188,21 @@ export async function POST(request: Request) {
visitId,
urlPath: safeDecodeURI(urlPath),
urlQuery,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
referrerPath: safeDecodeURI(referrerPath),
referrerQuery,
referrerDomain,
pageTitle: safeDecodeURIComponent(title),
gclid,
fbclid,
msclkid,
ttclid,
lifatid,
twclid,
eventName: name,
eventData: data,
hostname: hostname || urlDomain,
@ -184,8 +212,7 @@ export async function POST(request: Request) {
screen,
language,
country,
subdivision1,
subdivision2,
region,
city,
tag,
createdAt,

View file

@ -1,6 +0,0 @@
import { json } from '@/lib/response';
import { CURRENT_VERSION } from '@/lib/constants';
export async function GET() {
return json({ version: CURRENT_VERSION });
}