mirror of
https://github.com/umami-software/umami.git
synced 2026-02-19 03:55:37 +01:00
Updated reports.
This commit is contained in:
parent
28e872f219
commit
01bd21c5b4
75 changed files with 1373 additions and 980 deletions
|
|
@ -32,7 +32,7 @@ export function WebsiteFilterButton({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogTrigger>
|
<DialogTrigger>
|
||||||
<Button variant="quiet">
|
<Button variant="outline">
|
||||||
<Icon>
|
<Icon>
|
||||||
<ListFilter />
|
<ListFilter />
|
||||||
</Icon>
|
</Icon>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export function WebsiteHeader() {
|
||||||
const website = useWebsite();
|
const website = useWebsite();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />}>
|
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />} showBorder={false}>
|
||||||
<Row alignItems="center" gap>
|
<Row alignItems="center" gap>
|
||||||
<ActiveUsers websiteId={website.id} />
|
<ActiveUsers websiteId={website.id} />
|
||||||
<Button>
|
<Button>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,18 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { Grid, Column } from '@umami/react-zen';
|
import { Column } from '@umami/react-zen';
|
||||||
import { WebsiteProvider } from './WebsiteProvider';
|
import { WebsiteProvider } from './WebsiteProvider';
|
||||||
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
|
|
||||||
import { PageBody } from '@/components/common/PageBody';
|
import { PageBody } from '@/components/common/PageBody';
|
||||||
import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
|
import { WebsiteHeader } from './WebsiteHeader';
|
||||||
|
import { WebsiteTabs } from './WebsiteTabs';
|
||||||
|
|
||||||
export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
|
export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<WebsiteProvider websiteId={websiteId}>
|
<WebsiteProvider websiteId={websiteId}>
|
||||||
<PageBody>
|
<PageBody>
|
||||||
<Column gap="6">
|
|
||||||
<WebsiteHeader />
|
<WebsiteHeader />
|
||||||
<Grid columns="auto 1fr" justifyContent="center" gap="6" width="100%">
|
<WebsiteTabs />
|
||||||
<Column position="sticky" top="20px" alignSelf="flex-start" width="200px">
|
|
||||||
<WebsiteNav websiteId={websiteId} />
|
|
||||||
</Column>
|
|
||||||
<Column>{children}</Column>
|
<Column>{children}</Column>
|
||||||
</Grid>
|
|
||||||
</Column>
|
|
||||||
</PageBody>
|
</PageBody>
|
||||||
</WebsiteProvider>
|
</WebsiteProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
64
src/app/(main)/websites/[websiteId]/WebsiteTabs.tsx
Normal file
64
src/app/(main)/websites/[websiteId]/WebsiteTabs.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { Tabs, TabList, Tab, Icon, Text, Row } from '@umami/react-zen';
|
||||||
|
import { useWebsite } from '@/components/hooks/useWebsite';
|
||||||
|
import { useMessages, useNavigation } from '@/components/hooks';
|
||||||
|
import { Clock, Eye, Lightning, User, ChartPie } from '@/components/icons';
|
||||||
|
|
||||||
|
export function WebsiteTabs() {
|
||||||
|
const website = useWebsite();
|
||||||
|
const { pathname, renderTeamUrl } = useNavigation();
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{
|
||||||
|
id: 'overview',
|
||||||
|
label: formatMessage(labels.overview),
|
||||||
|
icon: <Eye />,
|
||||||
|
path: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'events',
|
||||||
|
label: formatMessage(labels.events),
|
||||||
|
icon: <Lightning />,
|
||||||
|
path: '/events',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sessions',
|
||||||
|
label: formatMessage(labels.users),
|
||||||
|
icon: <User />,
|
||||||
|
path: '/sessions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'realtime',
|
||||||
|
label: formatMessage(labels.realtime),
|
||||||
|
icon: <Clock />,
|
||||||
|
path: '/realtime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reports',
|
||||||
|
label: formatMessage(labels.reports),
|
||||||
|
icon: <ChartPie />,
|
||||||
|
path: '/reports',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedKey = links.find(({ path }) => path && pathname.includes(path))?.id || 'overview';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row marginBottom="6">
|
||||||
|
<Tabs selectedKey={selectedKey}>
|
||||||
|
<TabList>
|
||||||
|
{links.map(({ id, label, icon, path }) => {
|
||||||
|
return (
|
||||||
|
<Tab key={id} id={id} href={renderTeamUrl(`/websites/${website.id}${path}`)}>
|
||||||
|
<Row alignItems="center" gap>
|
||||||
|
<Icon>{icon}</Icon>
|
||||||
|
<Text>{label}</Text>
|
||||||
|
</Row>
|
||||||
|
</Tab>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TabList>
|
||||||
|
</Tabs>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { PageBody } from '@/components/common/PageBody';
|
|
||||||
import { SectionHeader } from '@/components/common/SectionHeader';
|
|
||||||
import { useApi, useMessages } from '@/components/hooks';
|
|
||||||
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
|
|
||||||
|
|
||||||
export function RealtimeHome() {
|
|
||||||
const { formatMessage, labels, messages } = useMessages();
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const router = useRouter();
|
|
||||||
const { data, isLoading, error } = useQuery({
|
|
||||||
queryKey: ['websites:me'],
|
|
||||||
queryFn: () => get('/me/websites'),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data?.length) {
|
|
||||||
router.push(`realtime/${data[0].id}`);
|
|
||||||
}
|
|
||||||
}, [data, router]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageBody isLoading={isLoading || data?.length > 0} error={error}>
|
|
||||||
<SectionHeader title={formatMessage(labels.realtime)} />
|
|
||||||
{data?.length === 0 && (
|
|
||||||
<EmptyPlaceholder message={formatMessage(messages.noWebsitesConfigured)} />
|
|
||||||
)}
|
|
||||||
</PageBody>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
'use client';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Grid, Column } from '@umami/react-zen';
|
||||||
|
import { ReportsNav } from './ReportsNav';
|
||||||
|
|
||||||
|
export function ReportsLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Grid columns="200px 1fr" gap="6">
|
||||||
|
<Column>
|
||||||
|
<ReportsNav websiteId={websiteId} />
|
||||||
|
</Column>
|
||||||
|
<Column minWidth="0">{children}</Column>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/app/(main)/websites/[websiteId]/reports/ReportsNav.tsx
Normal file
81
src/app/(main)/websites/[websiteId]/reports/ReportsNav.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { Row, NavMenu, NavMenuItem, Icon, Text } from '@umami/react-zen';
|
||||||
|
import { useMessages, useNavigation } from '@/components/hooks';
|
||||||
|
import { Funnel, Lightbulb, Magnet, Money, Network, Path, Tag, Target } from '@/components/icons';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export function ReportsNav({ websiteId }: { websiteId: string }) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { pathname, renderTeamUrl } = useNavigation();
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{
|
||||||
|
id: 'goals',
|
||||||
|
label: formatMessage(labels.goals),
|
||||||
|
icon: <Target />,
|
||||||
|
path: '/goals',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'funnel',
|
||||||
|
label: formatMessage(labels.funnels),
|
||||||
|
icon: <Funnel />,
|
||||||
|
path: '/funnels',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'journeys',
|
||||||
|
label: formatMessage(labels.journeys),
|
||||||
|
icon: <Path />,
|
||||||
|
path: '/journeys',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'retention',
|
||||||
|
label: formatMessage(labels.retention),
|
||||||
|
icon: <Magnet />,
|
||||||
|
path: '/retention',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'utm',
|
||||||
|
label: formatMessage(labels.utm),
|
||||||
|
icon: <Tag />,
|
||||||
|
path: '/utm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'revenue',
|
||||||
|
label: formatMessage(labels.revenue),
|
||||||
|
icon: <Money />,
|
||||||
|
path: '/revenue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'attribution',
|
||||||
|
label: formatMessage(labels.attribution),
|
||||||
|
icon: <Network />,
|
||||||
|
path: '/attribution',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'insights',
|
||||||
|
label: formatMessage(labels.insights),
|
||||||
|
icon: <Lightbulb />,
|
||||||
|
path: '/insights',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const selected = links.find(({ path }) => path && pathname.endsWith(path))?.id || 'goals';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavMenu highlightColor="3">
|
||||||
|
{links.map(({ id, label, icon, path }) => {
|
||||||
|
const isSelected = selected === id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link key={id} href={renderTeamUrl(`/websites/${websiteId}/reports${path}`)}>
|
||||||
|
<NavMenuItem isSelected={isSelected}>
|
||||||
|
<Row alignItems="center" gap>
|
||||||
|
<Icon>{icon}</Icon>
|
||||||
|
<Text>{label}</Text>
|
||||||
|
</Row>
|
||||||
|
</NavMenuItem>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</NavMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import { Grid, Loading } from '@umami/react-zen';
|
||||||
import { SectionHeader } from '@/components/common/SectionHeader';
|
import { SectionHeader } from '@/components/common/SectionHeader';
|
||||||
import { Goal } from './Goal';
|
import { Goal } from './Goal';
|
||||||
import { GoalAddButton } from './GoalAddButton';
|
import { GoalAddButton } from './GoalAddButton';
|
||||||
import { WebsiteControls } from '../WebsiteControls';
|
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||||
import { useDateRange, useReportsQuery } from '@/components/hooks';
|
import { useDateRange, useReportsQuery } from '@/components/hooks';
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Grid, Loading } from '@umami/react-zen';
|
import { Grid, Column } from '@umami/react-zen';
|
||||||
import { SectionHeader } from '@/components/common/SectionHeader';
|
import { SectionHeader } from '@/components/common/SectionHeader';
|
||||||
import { Funnel } from './Funnel';
|
import { Funnel } from './Funnel';
|
||||||
import { FunnelAddButton } from './FunnelAddButton';
|
import { FunnelAddButton } from './FunnelAddButton';
|
||||||
import { WebsiteControls } from '../WebsiteControls';
|
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||||
import { useDateRange, useReportsQuery } from '@/components/hooks';
|
import { useDateRange, useReportsQuery } from '@/components/hooks';
|
||||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
|
@ -14,12 +14,8 @@ export function FunnelsPage({ websiteId }: { websiteId: string }) {
|
||||||
dateRange: { startDate, endDate },
|
dateRange: { startDate, endDate },
|
||||||
} = useDateRange(websiteId);
|
} = useDateRange(websiteId);
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return <Loading position="page" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Column gap>
|
||||||
<WebsiteControls websiteId={websiteId} />
|
<WebsiteControls websiteId={websiteId} />
|
||||||
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
|
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
|
||||||
<SectionHeader>
|
<SectionHeader>
|
||||||
|
|
@ -33,6 +29,6 @@ export function FunnelsPage({ websiteId }: { websiteId: string }) {
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
</LoadingPanel>
|
</LoadingPanel>
|
||||||
</>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
98
src/app/(main)/websites/[websiteId]/reports/goals/Goal.tsx
Normal file
98
src/app/(main)/websites/[websiteId]/reports/goals/Goal.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
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<GoalData>(type, {
|
||||||
|
websiteId,
|
||||||
|
dateRange: {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
parameters,
|
||||||
|
});
|
||||||
|
const isPage = parameters?.type === 'page';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingPanel isEmpty={!data} isLoading={isLoading} error={error}>
|
||||||
|
<Grid gap>
|
||||||
|
<Grid columns="1fr auto" gap>
|
||||||
|
<Column gap>
|
||||||
|
<Row>
|
||||||
|
<Text size="4" weight="bold">
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Row>
|
||||||
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<ReportEditButton id={id} name={name} type={type}>
|
||||||
|
{({ close }) => {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={formatMessage(labels.goal)}
|
||||||
|
variant="modal"
|
||||||
|
style={{ minHeight: 300, minWidth: 400 }}
|
||||||
|
>
|
||||||
|
<GoalEditForm id={id} websiteId={websiteId} onClose={close} />
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ReportEditButton>
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
<Row alignItems="center" justifyContent="space-between" gap>
|
||||||
|
<Text color="muted">
|
||||||
|
{formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
|
||||||
|
</Text>
|
||||||
|
<Text color="muted">{formatMessage(labels.conversionRate)}</Text>
|
||||||
|
</Row>
|
||||||
|
<Row alignItems="center" justifyContent="space-between" gap>
|
||||||
|
<Row alignItems="center" gap>
|
||||||
|
<Icon>{parameters.type === 'page' ? <File /> : <Lightning />}</Icon>
|
||||||
|
<Text>{parameters.value}</Text>
|
||||||
|
</Row>
|
||||||
|
<Row alignItems="center" gap>
|
||||||
|
<Icon>
|
||||||
|
<User />
|
||||||
|
</Icon>
|
||||||
|
<Text title={`${data?.num} / ${data?.total}`}>{`${formatLongNumber(
|
||||||
|
data?.num,
|
||||||
|
)} / ${formatLongNumber(data?.total)}`}</Text>
|
||||||
|
</Row>
|
||||||
|
</Row>
|
||||||
|
<Row alignItems="center" gap="6">
|
||||||
|
<ProgressBar
|
||||||
|
value={data?.num || 0}
|
||||||
|
minValue={0}
|
||||||
|
maxValue={data?.total || 1}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
<Text weight="bold" size="7">
|
||||||
|
{data?.total ? Math.round((+data?.num / +data?.total) * 100) : '0'}%
|
||||||
|
</Text>
|
||||||
|
</Row>
|
||||||
|
</Grid>
|
||||||
|
</LoadingPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
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 (
|
||||||
|
<DialogTrigger>
|
||||||
|
<Button variant="primary">
|
||||||
|
<Icon>
|
||||||
|
<Plus />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.goal)}</Text>
|
||||||
|
</Button>
|
||||||
|
<Modal>
|
||||||
|
<Dialog
|
||||||
|
variant="modal"
|
||||||
|
title={formatMessage(labels.goal)}
|
||||||
|
style={{ minHeight: 375, minWidth: 400 }}
|
||||||
|
>
|
||||||
|
{({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</DialogTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
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 <Loading position="page" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValues = {
|
||||||
|
name: data?.name || '',
|
||||||
|
type: data?.parameters?.type || 'page',
|
||||||
|
value: data?.parameters?.value || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}>
|
||||||
|
{({ watch }) => {
|
||||||
|
const watchType = watch('type');
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
label={formatMessage(labels.name)}
|
||||||
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
|
>
|
||||||
|
<TextField />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name="type"
|
||||||
|
label={formatMessage(labels.type)}
|
||||||
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
|
>
|
||||||
|
<RadioGroup orientation="horizontal" variant="box">
|
||||||
|
<Grid columns="1fr 1fr" flexGrow={1} gap>
|
||||||
|
<Radio value="page">
|
||||||
|
<Icon>
|
||||||
|
<File />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.page)}</Text>
|
||||||
|
</Radio>
|
||||||
|
<Radio value="event">
|
||||||
|
<Icon>
|
||||||
|
<Lightning />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.event)}</Text>
|
||||||
|
</Radio>
|
||||||
|
</Grid>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name="value"
|
||||||
|
label={formatMessage(watchType === 'event' ? labels.eventName : labels.path)}
|
||||||
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
|
>
|
||||||
|
<TextField />
|
||||||
|
</FormField>
|
||||||
|
<FormButtons>
|
||||||
|
<Button onPress={onClose} isDisabled={isPending}>
|
||||||
|
{formatMessage(labels.cancel)}
|
||||||
|
</Button>
|
||||||
|
<FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
|
||||||
|
</FormButtons>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
'use client';
|
||||||
|
import { Grid, Column } 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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column gap>
|
||||||
|
<WebsiteControls websiteId={websiteId} />
|
||||||
|
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
|
||||||
|
<SectionHeader>
|
||||||
|
<GoalAddButton websiteId={websiteId} />
|
||||||
|
</SectionHeader>
|
||||||
|
<Grid columns="1fr 1fr" gap>
|
||||||
|
{result?.data?.map((report: any) => (
|
||||||
|
<Panel key={report.id}>
|
||||||
|
<Goal {...report} reportId={report.id} startDate={startDate} endDate={endDate} />
|
||||||
|
</Panel>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</LoadingPanel>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/app/(main)/websites/[websiteId]/reports/goals/page.tsx
Normal file
12
src/app/(main)/websites/[websiteId]/reports/goals/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { GoalsPage } from './GoalsPage';
|
||||||
|
|
||||||
|
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
return <GoalsPage websiteId={websiteId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Goals',
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
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<GoalData>(type, {
|
||||||
|
websiteId,
|
||||||
|
dateRange: {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
parameters,
|
||||||
|
});
|
||||||
|
const isPage = parameters?.type === 'page';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingPanel isEmpty={!data} isLoading={isLoading} error={error}>
|
||||||
|
<Grid gap>
|
||||||
|
<Grid columns="1fr auto" gap>
|
||||||
|
<Column gap>
|
||||||
|
<Row>
|
||||||
|
<Text size="4" weight="bold">
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Row>
|
||||||
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<ReportEditButton id={id} name={name} type={type}>
|
||||||
|
{({ close }) => {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={formatMessage(labels.goal)}
|
||||||
|
variant="modal"
|
||||||
|
style={{ minHeight: 300, minWidth: 400 }}
|
||||||
|
>
|
||||||
|
<GoalEditForm id={id} websiteId={websiteId} onClose={close} />
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ReportEditButton>
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
<Row alignItems="center" justifyContent="space-between" gap>
|
||||||
|
<Text color="muted">
|
||||||
|
{formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)}
|
||||||
|
</Text>
|
||||||
|
<Text color="muted">{formatMessage(labels.conversionRate)}</Text>
|
||||||
|
</Row>
|
||||||
|
<Row alignItems="center" justifyContent="space-between" gap>
|
||||||
|
<Row alignItems="center" gap>
|
||||||
|
<Icon>{parameters.type === 'page' ? <File /> : <Lightning />}</Icon>
|
||||||
|
<Text>{parameters.value}</Text>
|
||||||
|
</Row>
|
||||||
|
<Row alignItems="center" gap>
|
||||||
|
<Icon>
|
||||||
|
<User />
|
||||||
|
</Icon>
|
||||||
|
<Text title={`${data?.num} / ${data?.total}`}>{`${formatLongNumber(
|
||||||
|
data?.num,
|
||||||
|
)} / ${formatLongNumber(data?.total)}`}</Text>
|
||||||
|
</Row>
|
||||||
|
</Row>
|
||||||
|
<Row alignItems="center" gap="6">
|
||||||
|
<ProgressBar
|
||||||
|
value={data?.num || 0}
|
||||||
|
minValue={0}
|
||||||
|
maxValue={data?.total || 1}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
<Text weight="bold" size="7">
|
||||||
|
{data?.total ? Math.round((+data?.num / +data?.total) * 100) : '0'}%
|
||||||
|
</Text>
|
||||||
|
</Row>
|
||||||
|
</Grid>
|
||||||
|
</LoadingPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
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 (
|
||||||
|
<DialogTrigger>
|
||||||
|
<Button variant="primary">
|
||||||
|
<Icon>
|
||||||
|
<Plus />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.goal)}</Text>
|
||||||
|
</Button>
|
||||||
|
<Modal>
|
||||||
|
<Dialog
|
||||||
|
variant="modal"
|
||||||
|
title={formatMessage(labels.goal)}
|
||||||
|
style={{ minHeight: 375, minWidth: 400 }}
|
||||||
|
>
|
||||||
|
{({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</DialogTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
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 <Loading position="page" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValues = {
|
||||||
|
name: data?.name || '',
|
||||||
|
type: data?.parameters?.type || 'page',
|
||||||
|
value: data?.parameters?.value || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}>
|
||||||
|
{({ watch }) => {
|
||||||
|
const watchType = watch('type');
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
label={formatMessage(labels.name)}
|
||||||
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
|
>
|
||||||
|
<TextField />
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name="type"
|
||||||
|
label={formatMessage(labels.type)}
|
||||||
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
|
>
|
||||||
|
<RadioGroup orientation="horizontal" variant="box">
|
||||||
|
<Grid columns="1fr 1fr" flexGrow={1} gap>
|
||||||
|
<Radio value="page">
|
||||||
|
<Icon>
|
||||||
|
<File />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.page)}</Text>
|
||||||
|
</Radio>
|
||||||
|
<Radio value="event">
|
||||||
|
<Icon>
|
||||||
|
<Lightning />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatMessage(labels.event)}</Text>
|
||||||
|
</Radio>
|
||||||
|
</Grid>
|
||||||
|
</RadioGroup>
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
name="value"
|
||||||
|
label={formatMessage(watchType === 'event' ? labels.eventName : labels.path)}
|
||||||
|
rules={{ required: formatMessage(labels.required) }}
|
||||||
|
>
|
||||||
|
<TextField />
|
||||||
|
</FormField>
|
||||||
|
<FormButtons>
|
||||||
|
<Button onPress={onClose} isDisabled={isPending}>
|
||||||
|
{formatMessage(labels.cancel)}
|
||||||
|
</Button>
|
||||||
|
<FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
|
||||||
|
</FormButtons>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
'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 <Loading position="page" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<WebsiteControls websiteId={websiteId} />
|
||||||
|
<LoadingPanel isEmpty={!result?.data} isLoading={!result}>
|
||||||
|
<SectionHeader>
|
||||||
|
<GoalAddButton websiteId={websiteId} />
|
||||||
|
</SectionHeader>
|
||||||
|
<Grid columns="1fr 1fr" gap>
|
||||||
|
{result?.data?.map((report: any) => (
|
||||||
|
<Panel key={report.id}>
|
||||||
|
<Goal {...report} reportId={report.id} startDate={startDate} endDate={endDate} />
|
||||||
|
</Panel>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</LoadingPanel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { GoalsPage } from './GoalsPage';
|
||||||
|
|
||||||
|
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
return <GoalsPage websiteId={websiteId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Goals',
|
||||||
|
};
|
||||||
|
|
@ -166,7 +166,7 @@ export function Journey({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoadingPanel isEmpty={!data} isLoading={isLoading} error={error}>
|
<LoadingPanel isEmpty={!data} isLoading={isLoading} error={error} height="100%">
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.view}>
|
<div className={styles.view}>
|
||||||
{columns.map(({ visitorCount, nodes }, columnIndex) => {
|
{columns.map(({ visitorCount, nodes }, columnIndex) => {
|
||||||
|
|
@ -19,7 +19,7 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
|
||||||
const [endStep, setEndStep] = useState('');
|
const [endStep, setEndStep] = useState('');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap="6">
|
<Column gap>
|
||||||
<WebsiteControls websiteId={websiteId} />
|
<WebsiteControls websiteId={websiteId} />
|
||||||
<Grid columns="repeat(3, 1fr)" gap>
|
<Grid columns="repeat(3, 1fr)" gap>
|
||||||
<Select
|
<Select
|
||||||
21
src/app/(main)/websites/[websiteId]/reports/layout.tsx
Normal file
21
src/app/(main)/websites/[websiteId]/reports/layout.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { Metadata } from 'next';
|
||||||
|
import { ReportsLayout } from './ReportsLayout';
|
||||||
|
|
||||||
|
export default async function ({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: any;
|
||||||
|
params: Promise<{ websiteId: string }>;
|
||||||
|
}) {
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
return <ReportsLayout websiteId={websiteId}>{children}</ReportsLayout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: {
|
||||||
|
template: '%s | Umami',
|
||||||
|
default: 'Websites | Umami',
|
||||||
|
},
|
||||||
|
};
|
||||||
3
src/app/(main)/websites/[websiteId]/reports/page.tsx
Normal file
3
src/app/(main)/websites/[websiteId]/reports/page.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import GoalsPage from './goals/page';
|
||||||
|
|
||||||
|
export default GoalsPage;
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
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 RetentionProps {
|
||||||
|
websiteId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
days?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Retention({ websiteId, days = DAYS, startDate, endDate }: RetentionProps) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { locale } = useLocale();
|
||||||
|
const { data, error, isLoading } = useResultQuery<any>('retention', {
|
||||||
|
websiteId,
|
||||||
|
dateRange: {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Loading position="page" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <Empty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<LoadingPanel isEmpty={!data?.length} isLoading={isLoading} error={error}>
|
||||||
|
<Panel allowFullscreen height="900px">
|
||||||
|
<Column gap="1" width="100%" overflow="auto">
|
||||||
|
<Grid
|
||||||
|
columns="120px repeat(10, 100px)"
|
||||||
|
alignItems="center"
|
||||||
|
gap="1"
|
||||||
|
height="50px"
|
||||||
|
autoFlow="column"
|
||||||
|
>
|
||||||
|
<Column>
|
||||||
|
<Text weight="bold">{formatMessage(labels.cohort)}</Text>
|
||||||
|
</Column>
|
||||||
|
{days.map(n => (
|
||||||
|
<Column key={n}>
|
||||||
|
<Text weight="bold" align="center" wrap="nowrap">
|
||||||
|
{formatMessage(labels.day)} {n}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
{rows.map(({ date, visitors, records }: any, rowIndex: number) => {
|
||||||
|
return (
|
||||||
|
<Grid key={rowIndex} columns="120px repeat(10, 100px)" gap="1" autoFlow="column">
|
||||||
|
<Column justifyContent="center" gap="1">
|
||||||
|
<Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
|
||||||
|
<Row alignItems="center" gap>
|
||||||
|
<Icon>
|
||||||
|
<Users />
|
||||||
|
</Icon>
|
||||||
|
<Text>{formatLongNumber(visitors)}</Text>
|
||||||
|
</Row>
|
||||||
|
</Column>
|
||||||
|
{days.map(day => {
|
||||||
|
if (totalDays - rowIndex < day) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const percentage = records.filter(a => a.day === day)[0]?.percentage;
|
||||||
|
return (
|
||||||
|
<Cell key={day}>{percentage ? `${Number(percentage).toFixed(2)}%` : ''}</Cell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Column>
|
||||||
|
</Panel>
|
||||||
|
</LoadingPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Cell = ({ children }: { children: ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<Column
|
||||||
|
justifyContent="center"
|
||||||
|
alignItems="center"
|
||||||
|
width="100px"
|
||||||
|
height="100px"
|
||||||
|
backgroundColor="2"
|
||||||
|
borderRadius
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Column } from '@umami/react-zen';
|
import { Column } from '@umami/react-zen';
|
||||||
import { RetentionTable } from './RetentionTable';
|
import { Retention } from './Retention';
|
||||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||||
import { Panel } from '@/components/common/Panel';
|
import { useDateRange } from '@/components/hooks';
|
||||||
|
|
||||||
export function RetentionPage({ websiteId }: { websiteId: string }) {
|
export function RetentionPage({ websiteId }: { websiteId: string }) {
|
||||||
|
const {
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
} = useDateRange(websiteId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<WebsiteControls websiteId={websiteId} />
|
<WebsiteControls websiteId={websiteId} />
|
||||||
<Panel>
|
<Retention websiteId={websiteId} startDate={startDate} endDate={endDate} />
|
||||||
<RetentionTable websiteId={websiteId} />
|
|
||||||
</Panel>
|
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
200
src/app/(main)/websites/[websiteId]/reports/revenue/Revenue.tsx
Normal file
200
src/app/(main)/websites/[websiteId]/reports/revenue/Revenue.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Grid, Select, ListItem } from '@umami/react-zen';
|
||||||
|
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, useResultQuery } from '@/components/hooks';
|
||||||
|
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, CURRENCIES } from '@/lib/constants';
|
||||||
|
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { Column } from '@umami/react-zen';
|
||||||
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
|
import { parseDateRange } from '@/lib/date';
|
||||||
|
|
||||||
|
export interface RevenueProps {
|
||||||
|
websiteId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Revenue({ websiteId, startDate, endDate }: RevenueProps) {
|
||||||
|
const [currency, setCurrency] = useState('USD');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { locale } = useLocale();
|
||||||
|
const { countryNames } = useCountryNames(locale);
|
||||||
|
const { unit } = parseDateRange({ startDate, endDate }, locale);
|
||||||
|
const { data, error, isLoading } = useResultQuery<any>('revenue', {
|
||||||
|
websiteId,
|
||||||
|
dateRange: {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
currency,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const isEmpty = !Object.keys(data || {})?.length;
|
||||||
|
|
||||||
|
const renderCountryName = useCallback(
|
||||||
|
({ x: code }) => (
|
||||||
|
<span className={classNames(locale)}>
|
||||||
|
<TypeIcon type="country" value={code?.toLowerCase()} />
|
||||||
|
{countryNames[code]}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
[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 (
|
||||||
|
<Column gap>
|
||||||
|
<Grid columns="280px" gap>
|
||||||
|
<Select
|
||||||
|
items={CURRENCIES}
|
||||||
|
label={formatMessage(labels.currency)}
|
||||||
|
value={currency}
|
||||||
|
defaultValue={currency}
|
||||||
|
onChange={setCurrency}
|
||||||
|
listProps={{ style: { maxHeight: '300px' } }}
|
||||||
|
onSearch={setSearch}
|
||||||
|
allowSearch
|
||||||
|
>
|
||||||
|
{CURRENCIES.map(({ id, name }) => {
|
||||||
|
if (search && !`${id}${name}`.toLowerCase().includes(search)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem key={id} id={id}>
|
||||||
|
{id} — {name}
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
}).filter(n => n)}
|
||||||
|
</Select>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<LoadingPanel isEmpty={isEmpty} isLoading={isLoading} error={error}>
|
||||||
|
<Column gap>
|
||||||
|
<Panel>
|
||||||
|
<MetricsBar isFetched={!!data}>
|
||||||
|
{metricData?.map(({ label, value, formatValue }) => {
|
||||||
|
return (
|
||||||
|
<MetricCard key={label} value={value} label={label} formatValue={formatValue} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</MetricsBar>
|
||||||
|
</Panel>
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<Panel>
|
||||||
|
<BarChart
|
||||||
|
minDate={startDate}
|
||||||
|
maxDate={endDate}
|
||||||
|
data={chartData}
|
||||||
|
unit={unit}
|
||||||
|
stacked={true}
|
||||||
|
currency={currency}
|
||||||
|
renderXLabel={renderDateLabels(unit, locale)}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
<Panel>
|
||||||
|
<Grid columns="1fr 1fr">
|
||||||
|
<ListTable
|
||||||
|
metric={formatMessage(labels.country)}
|
||||||
|
data={data?.country.map(({ name, value }) => ({
|
||||||
|
x: name,
|
||||||
|
y: Number(value),
|
||||||
|
z: (value / data?.total.sum) * 100,
|
||||||
|
}))}
|
||||||
|
renderLabel={renderCountryName}
|
||||||
|
/>
|
||||||
|
<PieChart type="doughnut" data={countryData} />
|
||||||
|
</Grid>
|
||||||
|
</Panel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Column>
|
||||||
|
</LoadingPanel>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Column } from '@umami/react-zen';
|
import { Column } from '@umami/react-zen';
|
||||||
import { RevenueView } from './RevenueView';
|
import { Revenue } from './Revenue';
|
||||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||||
|
import { useDateRange } from '@/components/hooks';
|
||||||
|
|
||||||
export function RevenuePage({ websiteId }: { websiteId: string }) {
|
export function RevenuePage({ websiteId }: { websiteId: string }) {
|
||||||
|
const {
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
} = useDateRange(websiteId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<WebsiteControls websiteId={websiteId} allowCompare={false} />
|
<WebsiteControls websiteId={websiteId} allowCompare={false} />
|
||||||
<RevenueView websiteId={websiteId} />
|
<Revenue websiteId={websiteId} startDate={startDate} endDate={endDate} />
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -8,5 +8,5 @@ export default async function ({ params }: { params: Promise<{ websiteId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Revenue UTM Parameters',
|
title: 'Revenue',
|
||||||
};
|
};
|
||||||
81
src/app/(main)/websites/[websiteId]/reports/utm/UTM.tsx
Normal file
81
src/app/(main)/websites/[websiteId]/reports/utm/UTM.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { Grid, Column, Heading, Text } from '@umami/react-zen';
|
||||||
|
import { firstBy } from 'thenby';
|
||||||
|
import { CHART_COLORS, UTM_PARAMS } from '@/lib/constants';
|
||||||
|
import { useResultQuery } from '@/components/hooks';
|
||||||
|
import { PieChart } from '@/components/charts/PieChart';
|
||||||
|
import { ListTable } from '@/components/metrics/ListTable';
|
||||||
|
import { useMessages } from '@/components/hooks';
|
||||||
|
import { Panel } from '@/components/common/Panel';
|
||||||
|
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||||
|
|
||||||
|
export interface UTMProps {
|
||||||
|
websiteId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UTM({ websiteId, startDate, endDate }: UTMProps) {
|
||||||
|
const { formatMessage, labels } = useMessages();
|
||||||
|
const { data, error, isLoading } = useResultQuery<any>('utm', {
|
||||||
|
websiteId,
|
||||||
|
dateRange: {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const isEmpty = !Object.keys(data || {})?.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingPanel isEmpty={isEmpty} isLoading={isLoading} error={error}>
|
||||||
|
<Column gap>
|
||||||
|
{UTM_PARAMS.map(param => {
|
||||||
|
const items = toArray(data?.[param]);
|
||||||
|
const chartData = {
|
||||||
|
labels: items.map(({ name }) => name),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: items.map(({ value }) => value),
|
||||||
|
backgroundColor: CHART_COLORS,
|
||||||
|
borderWidth: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const total = items.reduce((sum, { value }) => {
|
||||||
|
return +sum + +value;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel key={param}>
|
||||||
|
<Grid columns="1fr 1fr">
|
||||||
|
<Column>
|
||||||
|
<Heading>
|
||||||
|
<Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>
|
||||||
|
</Heading>
|
||||||
|
<ListTable
|
||||||
|
metric={formatMessage(labels.views)}
|
||||||
|
data={items.map(({ name, value }) => ({
|
||||||
|
x: name,
|
||||||
|
y: value,
|
||||||
|
z: (value / total) * 100,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
<Column>
|
||||||
|
<PieChart type="doughnut" data={chartData} />
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Column>
|
||||||
|
</LoadingPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArray(data: { [key: string]: number } = {}) {
|
||||||
|
return Object.keys(data)
|
||||||
|
.map(key => {
|
||||||
|
return { name: key, value: data[key] };
|
||||||
|
})
|
||||||
|
.sort(firstBy('value', -1));
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
'use client';
|
'use client';
|
||||||
import { Column } from '@umami/react-zen';
|
import { Column } from '@umami/react-zen';
|
||||||
import { UTMView } from './UTMView';
|
import { useDateRange } from '@/components/hooks';
|
||||||
|
import { UTM } from './UTM';
|
||||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||||
|
|
||||||
export function UTMPage({ websiteId }: { websiteId: string }) {
|
export function UTMPage({ websiteId }: { websiteId: string }) {
|
||||||
|
const {
|
||||||
|
dateRange: { startDate, endDate },
|
||||||
|
} = useDateRange(websiteId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column gap>
|
<Column gap>
|
||||||
<WebsiteControls websiteId={websiteId} allowCompare={false} />
|
<WebsiteControls websiteId={websiteId} allowCompare={false} />
|
||||||
<UTMView websiteId={websiteId} />
|
<UTM websiteId={websiteId} startDate={startDate} endDate={endDate} />
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
import { ReactNode } from 'react';
|
|
||||||
import { Grid, Row, Column, Text, Loading, Icon } from '@umami/react-zen';
|
|
||||||
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
|
|
||||||
import { Users } from '@/components/icons';
|
|
||||||
import { useMessages, useLocale, useRetentionQuery } from '@/components/hooks';
|
|
||||||
import { formatDate } from '@/lib/date';
|
|
||||||
import { formatLongNumber } from '@/lib/format';
|
|
||||||
|
|
||||||
const DAYS = [1, 2, 3, 4, 5, 6, 7, 14, 21, 28];
|
|
||||||
|
|
||||||
export function RetentionTable({ websiteId, days = DAYS }: { websiteId: string; days?: number[] }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { locale } = useLocale();
|
|
||||||
const { data: x, isLoading } = useRetentionQuery(websiteId);
|
|
||||||
const data = x as any;
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Loading position="page" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return <EmptyPlaceholder />;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Column gap="1">
|
|
||||||
<Grid
|
|
||||||
columns="120px repeat(auto-fit, 100px)"
|
|
||||||
alignItems="center"
|
|
||||||
gap="1"
|
|
||||||
height="50px"
|
|
||||||
autoFlow="column"
|
|
||||||
>
|
|
||||||
<Column>
|
|
||||||
<Text weight="bold">{formatMessage(labels.cohort)}</Text>
|
|
||||||
</Column>
|
|
||||||
{days.map(n => (
|
|
||||||
<Column key={n}>
|
|
||||||
<Text weight="bold" align="center">
|
|
||||||
{formatMessage(labels.day)} {n}
|
|
||||||
</Text>
|
|
||||||
</Column>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
{rows.map(({ date, visitors, records }: any, rowIndex: number) => {
|
|
||||||
return (
|
|
||||||
<Grid key={rowIndex} columns="120px repeat(auto-fit, 100px)" gap="1" autoFlow="column">
|
|
||||||
<Column justifyContent="center" gap="1">
|
|
||||||
<Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
|
|
||||||
<Row alignItems="center" gap>
|
|
||||||
<Icon>
|
|
||||||
<Users />
|
|
||||||
</Icon>
|
|
||||||
<Text>{formatLongNumber(visitors)}</Text>
|
|
||||||
</Row>
|
|
||||||
</Column>
|
|
||||||
{days.map(day => {
|
|
||||||
if (totalDays - rowIndex < day) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const percentage = records.filter(a => a.day === day)[0]?.percentage;
|
|
||||||
return <Cell key={day}>{percentage ? `${Number(percentage).toFixed(2)}%` : ''}</Cell>;
|
|
||||||
})}
|
|
||||||
</Grid>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Cell = ({ children }: { children: ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<Column
|
|
||||||
justifyContent="center"
|
|
||||||
alignItems="center"
|
|
||||||
width="100px"
|
|
||||||
height="100px"
|
|
||||||
backgroundColor="2"
|
|
||||||
borderRadius
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
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,
|
|
||||||
useRevenueQuery,
|
|
||||||
useDateRange,
|
|
||||||
} 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 { renderDateLabels } from '@/lib/charts';
|
|
||||||
import { CHART_COLORS } from '@/lib/constants';
|
|
||||||
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
|
|
||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
import { RevenueTable } from './RevenueTable';
|
|
||||||
import { Panel } from '@/components/common/Panel';
|
|
||||||
import { Column } from '@umami/react-zen';
|
|
||||||
|
|
||||||
export interface RevenueViewProps {
|
|
||||||
websiteId: string;
|
|
||||||
isLoading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RevenueView({ websiteId, isLoading }: RevenueViewProps) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { locale } = useLocale();
|
|
||||||
const { countryNames } = useCountryNames(locale);
|
|
||||||
|
|
||||||
const { data } = useRevenueQuery(websiteId);
|
|
||||||
const currency = 'USD';
|
|
||||||
const { dateRange } = useDateRange(websiteId);
|
|
||||||
|
|
||||||
const renderCountryName = useCallback(
|
|
||||||
({ x: code }) => (
|
|
||||||
<span className={classNames(locale)}>
|
|
||||||
<TypeIcon type="country" value={code?.toLowerCase()} />
|
|
||||||
{countryNames[code]}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
[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 (
|
|
||||||
<>
|
|
||||||
<Column gap>
|
|
||||||
<Panel>
|
|
||||||
<MetricsBar isFetched={!!data}>
|
|
||||||
{metricData?.map(({ label, value, formatValue }) => {
|
|
||||||
return (
|
|
||||||
<MetricCard key={label} value={value} label={label} formatValue={formatValue} />
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</MetricsBar>
|
|
||||||
</Panel>
|
|
||||||
{data && (
|
|
||||||
<>
|
|
||||||
<Panel>
|
|
||||||
<BarChart
|
|
||||||
minDate={dateRange?.startDate}
|
|
||||||
maxDate={dateRange?.endDate}
|
|
||||||
data={chartData}
|
|
||||||
unit={dateRange?.unit}
|
|
||||||
stacked={true}
|
|
||||||
currency={currency}
|
|
||||||
renderXLabel={renderDateLabels(dateRange?.unit, locale)}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</Panel>
|
|
||||||
<Panel>
|
|
||||||
<GridRow layout="two">
|
|
||||||
<ListTable
|
|
||||||
metric={formatMessage(labels.country)}
|
|
||||||
data={data?.country.map(({ name, value }) => ({
|
|
||||||
x: name,
|
|
||||||
y: Number(value),
|
|
||||||
z: (value / data?.total.sum) * 100,
|
|
||||||
}))}
|
|
||||||
renderLabel={renderCountryName}
|
|
||||||
/>
|
|
||||||
<PieChart type="doughnut" data={countryData} />
|
|
||||||
</GridRow>
|
|
||||||
</Panel>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Panel>
|
|
||||||
<RevenueTable data={data?.table} />
|
|
||||||
</Panel>
|
|
||||||
</Column>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import { Column, Heading, Text, Loading } from '@umami/react-zen';
|
|
||||||
import { firstBy } from 'thenby';
|
|
||||||
import { CHART_COLORS, UTM_PARAMS } from '@/lib/constants';
|
|
||||||
import { useUTMQuery } from '@/components/hooks';
|
|
||||||
import { PieChart } from '@/components/charts/PieChart';
|
|
||||||
import { ListTable } from '@/components/metrics/ListTable';
|
|
||||||
import { useMessages } from '@/components/hooks';
|
|
||||||
import { Panel } from '@/components/common/Panel';
|
|
||||||
import { GridRow } from '@/components/common/GridRow';
|
|
||||||
|
|
||||||
function toArray(data: { [key: string]: number } = {}) {
|
|
||||||
return Object.keys(data)
|
|
||||||
.map(key => {
|
|
||||||
return { name: key, value: data[key] };
|
|
||||||
})
|
|
||||||
.sort(firstBy('value', -1));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UTMView({ websiteId }: { websiteId: string }) {
|
|
||||||
const { formatMessage, labels } = useMessages();
|
|
||||||
const { data, isLoading } = useUTMQuery(websiteId);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Loading position="page" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column gap>
|
|
||||||
{UTM_PARAMS.map(param => {
|
|
||||||
const items = toArray(data[param]);
|
|
||||||
const chartData = {
|
|
||||||
labels: items.map(({ name }) => name),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
data: items.map(({ value }) => value),
|
|
||||||
backgroundColor: CHART_COLORS,
|
|
||||||
borderWidth: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const total = items.reduce((sum, { value }) => {
|
|
||||||
return +sum + +value;
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Panel key={param}>
|
|
||||||
<GridRow layout="two">
|
|
||||||
<Column>
|
|
||||||
<Heading>
|
|
||||||
<Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>
|
|
||||||
</Heading>
|
|
||||||
<ListTable
|
|
||||||
metric={formatMessage(labels.views)}
|
|
||||||
data={items.map(({ name, value }) => ({
|
|
||||||
x: name,
|
|
||||||
y: value,
|
|
||||||
z: (value / total) * 100,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Column>
|
|
||||||
<Column>
|
|
||||||
<PieChart type="doughnut" data={chartData} />
|
|
||||||
</Column>
|
|
||||||
</GridRow>
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +1,11 @@
|
||||||
import { z } from 'zod';
|
|
||||||
import { canViewWebsite } from '@/lib/auth';
|
import { canViewWebsite } from '@/lib/auth';
|
||||||
import { unauthorized, json } from '@/lib/response';
|
import { unauthorized, json } from '@/lib/response';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { getInsights } from '@/queries';
|
import { getInsights } from '@/queries';
|
||||||
import { reportParms } from '@/lib/schema';
|
import { reportResultSchema } from '@/lib/schema';
|
||||||
|
|
||||||
function convertFilters(filters: any[]) {
|
|
||||||
return filters.reduce((obj, filter) => {
|
|
||||||
obj[filter.name] = filter;
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const schema = z.object({
|
const { auth, body, error } = await parseRequest(request, reportResultSchema);
|
||||||
...reportParms,
|
|
||||||
fields: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
name: z.string(),
|
|
||||||
type: z.string(),
|
|
||||||
label: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.min(1),
|
|
||||||
filters: z.array(
|
|
||||||
z.object({
|
|
||||||
name: z.string(),
|
|
||||||
type: z.string(),
|
|
||||||
operator: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return error();
|
return error();
|
||||||
|
|
@ -60,3 +30,11 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
return json(data);
|
return json(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function convertFilters(filters: any[]) {
|
||||||
|
return filters.reduce((obj, filter) => {
|
||||||
|
obj[filter.name] = filter;
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,11 @@
|
||||||
import { z } from 'zod';
|
|
||||||
import { canViewWebsite } from '@/lib/auth';
|
import { canViewWebsite } from '@/lib/auth';
|
||||||
import { unauthorized, json } from '@/lib/response';
|
import { unauthorized, json } from '@/lib/response';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { getRetention } from '@/queries';
|
import { getRetention } from '@/queries';
|
||||||
import { reportParms, timezoneParam } from '@/lib/schema';
|
import { reportResultSchema } from '@/lib/schema';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const schema = z.object({
|
const { auth, body, error } = await parseRequest(request, reportResultSchema);
|
||||||
...reportParms,
|
|
||||||
timezone: timezoneParam,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return error();
|
return error();
|
||||||
|
|
@ -20,7 +14,6 @@ export async function POST(request: Request) {
|
||||||
const {
|
const {
|
||||||
websiteId,
|
websiteId,
|
||||||
dateRange: { startDate, endDate },
|
dateRange: { startDate, endDate },
|
||||||
timezone,
|
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!(await canViewWebsite(auth, websiteId))) {
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
|
@ -30,7 +23,6 @@ export async function POST(request: Request) {
|
||||||
const data = await getRetention(websiteId, {
|
const data = await getRetention(websiteId, {
|
||||||
startDate: new Date(startDate),
|
startDate: new Date(startDate),
|
||||||
endDate: new Date(endDate),
|
endDate: new Date(endDate),
|
||||||
timezone,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return json(data);
|
return json(data);
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,11 @@
|
||||||
import { z } from 'zod';
|
|
||||||
import { canViewWebsite } from '@/lib/auth';
|
import { canViewWebsite } from '@/lib/auth';
|
||||||
import { unauthorized, json } from '@/lib/response';
|
import { unauthorized, json } from '@/lib/response';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { reportParms, timezoneParam } from '@/lib/schema';
|
import { reportResultSchema } from '@/lib/schema';
|
||||||
import { getRevenue } from '@/queries/sql/reports/getRevenue';
|
import { getRevenue } from '@/queries/sql/reports/getRevenue';
|
||||||
import { getRevenueValues } from '@/queries/sql/reports/getRevenueValues';
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const { auth, query, error } = await parseRequest(request);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return error();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { websiteId, startDate, endDate } = query;
|
|
||||||
|
|
||||||
if (!(await canViewWebsite(auth, websiteId))) {
|
|
||||||
return unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await getRevenueValues(websiteId, {
|
|
||||||
startDate: new Date(startDate),
|
|
||||||
endDate: new Date(endDate),
|
|
||||||
});
|
|
||||||
|
|
||||||
return json(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const schema = z.object({
|
const { auth, body, error } = await parseRequest(request, reportResultSchema);
|
||||||
currency: z.string(),
|
|
||||||
...reportParms,
|
|
||||||
timezone: timezoneParam,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return error();
|
return error();
|
||||||
|
|
@ -43,7 +14,6 @@ export async function POST(request: Request) {
|
||||||
const {
|
const {
|
||||||
websiteId,
|
websiteId,
|
||||||
currency,
|
currency,
|
||||||
timezone,
|
|
||||||
dateRange: { startDate, endDate, unit },
|
dateRange: { startDate, endDate, unit },
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
|
|
@ -55,7 +25,6 @@ export async function POST(request: Request) {
|
||||||
startDate: new Date(startDate),
|
startDate: new Date(startDate),
|
||||||
endDate: new Date(endDate),
|
endDate: new Date(endDate),
|
||||||
unit,
|
unit,
|
||||||
timezone,
|
|
||||||
currency,
|
currency,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,11 @@
|
||||||
import { z } from 'zod';
|
|
||||||
import { canViewWebsite } from '@/lib/auth';
|
import { canViewWebsite } from '@/lib/auth';
|
||||||
import { unauthorized, json } from '@/lib/response';
|
import { unauthorized, json } from '@/lib/response';
|
||||||
import { parseRequest } from '@/lib/request';
|
import { parseRequest } from '@/lib/request';
|
||||||
import { getUTM } from '@/queries';
|
import { getUTM } from '@/queries';
|
||||||
import { reportParms } from '@/lib/schema';
|
import { reportResultSchema } from '@/lib/schema';
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const schema = z.object({
|
const { auth, body, error } = await parseRequest(request, reportResultSchema);
|
||||||
...reportParms,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { auth, body, error } = await parseRequest(request, schema);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return error();
|
return error();
|
||||||
|
|
@ -18,7 +13,7 @@ export async function POST(request: Request) {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
websiteId,
|
websiteId,
|
||||||
dateRange: { startDate, endDate, timezone },
|
dateRange: { startDate, endDate },
|
||||||
} = body;
|
} = body;
|
||||||
|
|
||||||
if (!(await canViewWebsite(auth, websiteId))) {
|
if (!(await canViewWebsite(auth, websiteId))) {
|
||||||
|
|
@ -28,7 +23,6 @@ export async function POST(request: Request) {
|
||||||
const data = await getUTM(websiteId, {
|
const data = await getUTM(websiteId, {
|
||||||
startDate: new Date(startDate),
|
startDate: new Date(startDate),
|
||||||
endDate: new Date(endDate),
|
endDate: new Date(endDate),
|
||||||
timezone,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return json(data);
|
return json(data);
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import { z } from 'zod';
|
|
||||||
import { canViewWebsite } from '@/lib/auth';
|
|
||||||
import { unauthorized, json } from '@/lib/response';
|
|
||||||
import { getRequestDateRange, parseRequest } from '@/lib/request';
|
|
||||||
import { getRetention } from '@/queries';
|
|
||||||
import { filterParams, timezoneParam, unitParam } from '@/lib/schema';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ websiteId: string }> },
|
|
||||||
) {
|
|
||||||
const schema = z.object({
|
|
||||||
startAt: z.coerce.number().int(),
|
|
||||||
endAt: z.coerce.number().int(),
|
|
||||||
unit: unitParam,
|
|
||||||
timezone: timezoneParam,
|
|
||||||
...filterParams,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { auth, query, error } = await parseRequest(request, schema);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return error();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { websiteId } = await params;
|
|
||||||
const { timezone } = query;
|
|
||||||
|
|
||||||
if (!(await canViewWebsite(auth, websiteId))) {
|
|
||||||
return unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { startDate, endDate } = await getRequestDateRange(query);
|
|
||||||
|
|
||||||
const data = await getRetention(websiteId, {
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
timezone,
|
|
||||||
});
|
|
||||||
|
|
||||||
return json(data);
|
|
||||||
}
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { z } from 'zod';
|
|
||||||
import { canViewWebsite } from '@/lib/auth';
|
|
||||||
import { unauthorized, json } from '@/lib/response';
|
|
||||||
import { getRequestDateRange, parseRequest } from '@/lib/request';
|
|
||||||
import { filterParams, unitParam, timezoneParam } from '@/lib/schema';
|
|
||||||
import { getRevenue } from '@/queries/sql/reports/getRevenue';
|
|
||||||
import { getRevenueValues } from '@/queries/sql/reports/getRevenueValues';
|
|
||||||
|
|
||||||
export async function __GET(request: Request) {
|
|
||||||
const { auth, query, error } = await parseRequest(request);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return error();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { websiteId, startDate, endDate } = query;
|
|
||||||
|
|
||||||
if (!(await canViewWebsite(auth, websiteId))) {
|
|
||||||
return unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await getRevenueValues(websiteId, {
|
|
||||||
startDate: new Date(startDate),
|
|
||||||
endDate: new Date(endDate),
|
|
||||||
});
|
|
||||||
|
|
||||||
return json(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ websiteId: string }> },
|
|
||||||
) {
|
|
||||||
const schema = z.object({
|
|
||||||
currency: z.string(),
|
|
||||||
startAt: z.coerce.number().int(),
|
|
||||||
endAt: z.coerce.number().int(),
|
|
||||||
unit: unitParam,
|
|
||||||
timezone: timezoneParam,
|
|
||||||
...filterParams,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { auth, query, error } = await parseRequest(request, schema);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return error();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { websiteId } = await params;
|
|
||||||
const { currency, timezone, unit } = query;
|
|
||||||
|
|
||||||
if (!(await canViewWebsite(auth, websiteId))) {
|
|
||||||
return unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { startDate, endDate } = await getRequestDateRange(query);
|
|
||||||
|
|
||||||
const data = await getRevenue(websiteId, {
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
unit,
|
|
||||||
timezone,
|
|
||||||
currency,
|
|
||||||
});
|
|
||||||
|
|
||||||
return json(data);
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import { z } from 'zod';
|
|
||||||
import { canViewWebsite } from '@/lib/auth';
|
|
||||||
import { unauthorized, json } from '@/lib/response';
|
|
||||||
import { getRequestDateRange, parseRequest } from '@/lib/request';
|
|
||||||
import { getUTM } from '@/queries';
|
|
||||||
import { filterParams, timezoneParam, unitParam } from '@/lib/schema';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: Request,
|
|
||||||
{ params }: { params: Promise<{ websiteId: string }> },
|
|
||||||
) {
|
|
||||||
const schema = z.object({
|
|
||||||
startAt: z.coerce.number().int(),
|
|
||||||
endAt: z.coerce.number().int(),
|
|
||||||
unit: unitParam,
|
|
||||||
timezone: timezoneParam,
|
|
||||||
...filterParams,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { auth, query, error } = await parseRequest(request, schema);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return error();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { websiteId } = await params;
|
|
||||||
const { timezone } = query;
|
|
||||||
|
|
||||||
if (!(await canViewWebsite(auth, websiteId))) {
|
|
||||||
return unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { startDate, endDate } = await getRequestDateRange(query);
|
|
||||||
|
|
||||||
const data = await getUTM(websiteId, {
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
timezone,
|
|
||||||
});
|
|
||||||
|
|
||||||
return json(data);
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
.container {
|
|
||||||
color: var(--base500);
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 70px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +1,16 @@
|
||||||
import classNames from 'classnames';
|
import { Row } from '@umami/react-zen';
|
||||||
import { useMessages } from '@/components/hooks';
|
import { useMessages } from '@/components/hooks';
|
||||||
import styles from './Empty.module.css';
|
|
||||||
|
|
||||||
export interface EmptyProps {
|
export interface EmptyProps {
|
||||||
message?: string;
|
message?: string;
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Empty({ message, className }: EmptyProps) {
|
export function Empty({ message }: EmptyProps) {
|
||||||
const { formatMessage, messages } = useMessages();
|
const { formatMessage, messages } = useMessages();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.container, className)}>
|
<Row color="muted" alignItems="center" justifyContent="center" width="100%" height="100%">
|
||||||
{message || formatMessage(messages.noDataAvailable)}
|
{message || formatMessage(messages.noDataAvailable)}
|
||||||
</div>
|
</Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,28 @@
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { Icon, Text, Column } from '@umami/react-zen';
|
import { Icon, Text, Column } from '@umami/react-zen';
|
||||||
import { Logo } from '@/components/icons';
|
|
||||||
|
|
||||||
export interface EmptyPlaceholderProps {
|
export interface EmptyPlaceholderProps {
|
||||||
message?: string;
|
title?: string;
|
||||||
|
description?: string;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyPlaceholder({ message, icon, children }: EmptyPlaceholderProps) {
|
export function EmptyPlaceholder({ title, description, icon, children }: EmptyPlaceholderProps) {
|
||||||
return (
|
return (
|
||||||
<Column alignItems="center" justifyContent="center" gap="5" height="100%" width="100%">
|
<Column alignItems="center" justifyContent="center" gap="5" height="100%" width="100%">
|
||||||
<Icon size="xl">{icon || <Logo />}</Icon>
|
{icon && (
|
||||||
<Text>{message}</Text>
|
<Icon color="10" size="xl">
|
||||||
<div>{children}</div>
|
{icon}
|
||||||
|
</Icon>
|
||||||
|
)}
|
||||||
|
{title && (
|
||||||
|
<Text weight="bold" size="4">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{description && <Text color="muted">{description}</Text>}
|
||||||
|
{children}
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState, Key } from 'react';
|
import { useState, Key } from 'react';
|
||||||
import { Grid, Row, Column, Label, List, ListItem, Button, Heading, Text } from '@umami/react-zen';
|
import { Grid, Row, Column, Label, List, ListItem, Button, Heading } from '@umami/react-zen';
|
||||||
import { useFilters, useMessages } from '@/components/hooks';
|
import { useFilters, useMessages } from '@/components/hooks';
|
||||||
import { FilterRecord } from '@/components/common/FilterRecord';
|
import { FilterRecord } from '@/components/common/FilterRecord';
|
||||||
|
import { Empty } from '@/components/common/Empty';
|
||||||
|
|
||||||
export interface FilterEditFormProps {
|
export interface FilterEditFormProps {
|
||||||
websiteId?: string;
|
websiteId?: string;
|
||||||
|
|
@ -11,7 +12,7 @@ export interface FilterEditFormProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilterEditForm({ data = [], onChange, onClose }: FilterEditFormProps) {
|
export function FilterEditForm({ data = [], onChange, onClose }: FilterEditFormProps) {
|
||||||
const { formatMessage, labels } = useMessages();
|
const { formatMessage, labels, messages } = useMessages();
|
||||||
const [filters, setFilters] = useState(data);
|
const [filters, setFilters] = useState(data);
|
||||||
const { fields } = useFilters();
|
const { fields } = useFilters();
|
||||||
|
|
||||||
|
|
@ -72,7 +73,7 @@ export function FilterEditForm({ data = [], onChange, onClose }: FilterEditFormP
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!filters.length && <Text align="center">{formatMessage(labels.none)}</Text>}
|
{!filters.length && <Empty message={formatMessage(messages.nothingSelected)} />}
|
||||||
</Column>
|
</Column>
|
||||||
<Row alignItems="center" justifyContent="flex-end" gridColumn="span 2" gap>
|
<Row alignItems="center" justifyContent="flex-end" gridColumn="span 2" gap>
|
||||||
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
|
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
.panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import classNames from 'classnames';
|
import { Spinner, Dots, Column, type ColumnProps } from '@umami/react-zen';
|
||||||
import { Spinner, Dots } from '@umami/react-zen';
|
|
||||||
import { ErrorMessage } from '@/components/common/ErrorMessage';
|
import { ErrorMessage } from '@/components/common/ErrorMessage';
|
||||||
import { Empty } from '@/components/common/Empty';
|
import { Empty } from '@/components/common/Empty';
|
||||||
import styles from './LoadingPanel.module.css';
|
|
||||||
|
|
||||||
export function LoadingPanel({
|
export function LoadingPanel({
|
||||||
error,
|
error,
|
||||||
|
|
@ -12,25 +10,23 @@ export function LoadingPanel({
|
||||||
isLoading,
|
isLoading,
|
||||||
loadingIcon = 'dots',
|
loadingIcon = 'dots',
|
||||||
renderEmpty = () => <Empty />,
|
renderEmpty = () => <Empty />,
|
||||||
className,
|
|
||||||
children,
|
children,
|
||||||
|
...props
|
||||||
}: {
|
}: {
|
||||||
data?: any;
|
|
||||||
error?: Error;
|
error?: Error;
|
||||||
isEmpty?: boolean;
|
isEmpty?: boolean;
|
||||||
isFetched?: boolean;
|
isFetched?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
loadingIcon?: 'dots' | 'spinner';
|
loadingIcon?: 'dots' | 'spinner';
|
||||||
renderEmpty?: () => ReactNode;
|
renderEmpty?: () => ReactNode;
|
||||||
className?: string;
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
} & ColumnProps) {
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.panel, className)}>
|
<Column {...props}>
|
||||||
{isLoading && !isFetched && (loadingIcon === 'dots' ? <Dots /> : <Spinner />)}
|
{isLoading && !isFetched && (loadingIcon === 'dots' ? <Dots /> : <Spinner />)}
|
||||||
{error && <ErrorMessage />}
|
{error && <ErrorMessage />}
|
||||||
{!error && !isLoading && isEmpty && renderEmpty()}
|
{!error && !isLoading && isEmpty && renderEmpty()}
|
||||||
{!error && !isLoading && !isEmpty && children}
|
{!error && !isLoading && !isEmpty && children}
|
||||||
</div>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { AlertBanner, Loading, Column } from '@umami/react-zen';
|
||||||
import { useMessages } from '@/components/hooks';
|
import { useMessages } from '@/components/hooks';
|
||||||
|
|
||||||
export function PageBody({
|
export function PageBody({
|
||||||
maxWidth = '1600px',
|
maxWidth = '1320px',
|
||||||
error,
|
error,
|
||||||
isLoading,
|
isLoading,
|
||||||
children,
|
children,
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@ export function PageHeader({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
icon,
|
icon,
|
||||||
|
showBorder = true,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
|
showBorder?: boolean;
|
||||||
allowEdit?: boolean;
|
allowEdit?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
|
@ -19,7 +21,7 @@ export function PageHeader({
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
paddingY="6"
|
paddingY="6"
|
||||||
border="bottom"
|
border={showBorder ? 'bottom' : undefined}
|
||||||
width="100%"
|
width="100%"
|
||||||
>
|
>
|
||||||
<Row alignItems="center" gap="3">
|
<Row alignItems="center" gap="3">
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,11 @@ export * from './queries/useActiveUsersQuery';
|
||||||
export * from './queries/useEventDataEventsQuery';
|
export * from './queries/useEventDataEventsQuery';
|
||||||
export * from './queries/useEventDataPropertiesQuery';
|
export * from './queries/useEventDataPropertiesQuery';
|
||||||
export * from './queries/useEventDataValuesQuery';
|
export * from './queries/useEventDataValuesQuery';
|
||||||
export * from './queries/useGoalsQuery';
|
|
||||||
export * from './queries/useLoginQuery';
|
export * from './queries/useLoginQuery';
|
||||||
export * from './queries/useRealtimeQuery';
|
export * from './queries/useRealtimeQuery';
|
||||||
export * from './queries/useResultQuery';
|
export * from './queries/useResultQuery';
|
||||||
export * from './queries/useReportQuery';
|
export * from './queries/useReportQuery';
|
||||||
export * from './queries/useReportsQuery';
|
export * from './queries/useReportsQuery';
|
||||||
export * from './queries/useRetentionQuery';
|
|
||||||
export * from './queries/useRevenueQuery';
|
|
||||||
export * from './queries/useSessionActivityQuery';
|
export * from './queries/useSessionActivityQuery';
|
||||||
export * from './queries/useSessionDataQuery';
|
export * from './queries/useSessionDataQuery';
|
||||||
export * from './queries/useSessionDataPropertiesQuery';
|
export * from './queries/useSessionDataPropertiesQuery';
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { useApi } from '../useApi';
|
|
||||||
import { usePagedQuery } from '../usePagedQuery';
|
|
||||||
|
|
||||||
export function useGoalQuery(
|
|
||||||
{ websiteId, reportId }: { websiteId: string; reportId: string },
|
|
||||||
params?: { [key: string]: string | number },
|
|
||||||
) {
|
|
||||||
const { post } = useApi();
|
|
||||||
|
|
||||||
return usePagedQuery({
|
|
||||||
queryKey: ['goal', { websiteId, reportId, ...params }],
|
|
||||||
queryFn: () => {
|
|
||||||
return post(`/reports/goals`, {
|
|
||||||
...params,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { useApi } from '../useApi';
|
|
||||||
import { usePagedQuery } from '../usePagedQuery';
|
|
||||||
import { useModified } from '../useModified';
|
|
||||||
|
|
||||||
export function useGoalsQuery(
|
|
||||||
{ websiteId }: { websiteId: string },
|
|
||||||
params?: { [key: string]: string | number },
|
|
||||||
) {
|
|
||||||
const { get } = useApi();
|
|
||||||
const { modified } = useModified(`goals`);
|
|
||||||
|
|
||||||
return usePagedQuery({
|
|
||||||
queryKey: ['goals', { websiteId, modified, ...params }],
|
|
||||||
queryFn: () => {
|
|
||||||
return get(`/websites/${websiteId}/goals`, {
|
|
||||||
...params,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { useApi } from '../useApi';
|
|
||||||
import { useFilterParams } from '../useFilterParams';
|
|
||||||
import { UseQueryOptions } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
export function useRetentionQuery(
|
|
||||||
websiteId: string,
|
|
||||||
queryParams?: { type: string; limit?: number; search?: string; startAt?: number; endAt?: number },
|
|
||||||
options?: Omit<UseQueryOptions & { onDataLoad?: (data: any) => void }, 'queryKey' | 'queryFn'>,
|
|
||||||
) {
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const filterParams = useFilterParams(websiteId);
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['retention', websiteId, { ...filterParams, ...queryParams }],
|
|
||||||
queryFn: () =>
|
|
||||||
get(`/websites/${websiteId}/retention`, { websiteId, ...filterParams, ...queryParams }),
|
|
||||||
enabled: !!websiteId,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import { useApi } from '../useApi';
|
|
||||||
import { useFilterParams } from '../useFilterParams';
|
|
||||||
import { UseQueryOptions } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
export interface RevenueData {
|
|
||||||
chart: any[];
|
|
||||||
country: any[];
|
|
||||||
total: {
|
|
||||||
sum: number;
|
|
||||||
count: number;
|
|
||||||
unique_count: number;
|
|
||||||
};
|
|
||||||
table: any[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRevenueQuery(
|
|
||||||
websiteId: string,
|
|
||||||
queryParams?: { type: string; limit?: number; search?: string; startAt?: number; endAt?: number },
|
|
||||||
options?: Omit<
|
|
||||||
UseQueryOptions<RevenueData, Error, RevenueData, any[]> & { onDataLoad?: (data: any) => void },
|
|
||||||
'queryKey' | 'queryFn'
|
|
||||||
>,
|
|
||||||
) {
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
const filterParams = useFilterParams(websiteId);
|
|
||||||
const currency = 'USD';
|
|
||||||
|
|
||||||
return useQuery<RevenueData, Error, RevenueData, any[]>({
|
|
||||||
queryKey: ['revenue', websiteId, { ...filterParams, ...queryParams }],
|
|
||||||
queryFn: () =>
|
|
||||||
get(`/websites/${websiteId}/revenue`, {
|
|
||||||
currency,
|
|
||||||
...filterParams,
|
|
||||||
...queryParams,
|
|
||||||
}),
|
|
||||||
enabled: !!websiteId,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { useApi } from '../useApi';
|
|
||||||
|
|
||||||
export function useRevenueValuesQuery(websiteId: string, startDate: Date, endDate: Date) {
|
|
||||||
const { get, useQuery } = useApi();
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['revenue:values', { websiteId, startDate, endDate }],
|
|
||||||
queryFn: () =>
|
|
||||||
get(`/reports/revenue`, {
|
|
||||||
websiteId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
}),
|
|
||||||
enabled: !!(websiteId && startDate && endDate),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -2,6 +2,7 @@ export {
|
||||||
AlertTriangle as Alert,
|
AlertTriangle as Alert,
|
||||||
ArrowRight as Arrow,
|
ArrowRight as Arrow,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
ChartPie,
|
||||||
ChevronRight as Chevron,
|
ChevronRight as Chevron,
|
||||||
Clock,
|
Clock,
|
||||||
X as Close,
|
X as Close,
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,7 @@ export const labels = defineMessages({
|
||||||
selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' },
|
selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' },
|
||||||
selectRole: { id: 'label.select-role', defaultMessage: 'Select role' },
|
selectRole: { id: 'label.select-role', defaultMessage: 'Select role' },
|
||||||
selectDate: { id: 'label.select-date', defaultMessage: 'Select date' },
|
selectDate: { id: 'label.select-date', defaultMessage: 'Select date' },
|
||||||
|
selectFilter: { id: 'label.select-filter', defaultMessage: 'Select filter' },
|
||||||
all: { id: 'label.all', defaultMessage: 'All' },
|
all: { id: 'label.all', defaultMessage: 'All' },
|
||||||
session: { id: 'label.session', defaultMessage: 'Session' },
|
session: { id: 'label.session', defaultMessage: 'Session' },
|
||||||
sessions: { id: 'label.sessions', defaultMessage: 'Sessions' },
|
sessions: { id: 'label.sessions', defaultMessage: 'Sessions' },
|
||||||
|
|
@ -331,6 +332,7 @@ export const messages = defineMessages({
|
||||||
noUsers: { id: 'message.no-users', defaultMessage: 'There are no users.' },
|
noUsers: { id: 'message.no-users', defaultMessage: 'There are no users.' },
|
||||||
userDeleted: { id: 'message.user-deleted', defaultMessage: 'User deleted.' },
|
userDeleted: { id: 'message.user-deleted', defaultMessage: 'User deleted.' },
|
||||||
noDataAvailable: { id: 'message.no-data-available', defaultMessage: 'No data available.' },
|
noDataAvailable: { id: 'message.no-data-available', defaultMessage: 'No data available.' },
|
||||||
|
nothingSelected: { id: 'message.nothing-selected', defaultMessage: 'Nothing selected.' },
|
||||||
confirmReset: {
|
confirmReset: {
|
||||||
id: 'message.confirm-reset',
|
id: 'message.confirm-reset',
|
||||||
defaultMessage: 'Are you sure you want to reset {target}?',
|
defaultMessage: 'Are you sure you want to reset {target}?',
|
||||||
|
|
|
||||||
|
|
@ -654,3 +654,56 @@ export const ISO_COUNTRIES = {
|
||||||
ZWE: 'ZW',
|
ZWE: 'ZW',
|
||||||
XKX: 'XK',
|
XKX: 'XK',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CURRENCIES = [
|
||||||
|
{ id: 'USD', name: 'US Dollar' },
|
||||||
|
{ id: 'EUR', name: 'Euro' },
|
||||||
|
{ id: 'GBP', name: 'British Pound' },
|
||||||
|
{ id: 'JPY', name: 'Japanese Yen' },
|
||||||
|
{ id: 'CNY', name: 'Chinese Renminbi (Yuan)' },
|
||||||
|
{ id: 'CAD', name: 'Canadian Dollar' },
|
||||||
|
{ id: 'HKD', name: 'Hong Kong Dollar' },
|
||||||
|
{ id: 'AUD', name: 'Australian Dollar' },
|
||||||
|
{ id: 'SGD', name: 'Singapore Dollar' },
|
||||||
|
{ id: 'CHF', name: 'Swiss Franc' },
|
||||||
|
{ id: 'SEK', name: 'Swedish Krona' },
|
||||||
|
{ id: 'PLN', name: 'Polish Złoty' },
|
||||||
|
{ id: 'NOK', name: 'Norwegian Krone' },
|
||||||
|
{ id: 'DKK', name: 'Danish Krone' },
|
||||||
|
{ id: 'NZD', name: 'New Zealand Dollar' },
|
||||||
|
{ id: 'ZAR', name: 'South African Rand' },
|
||||||
|
{ id: 'MXN', name: 'Mexican Peso' },
|
||||||
|
{ id: 'THB', name: 'Thai Baht' },
|
||||||
|
{ id: 'HUF', name: 'Hungarian Forint' },
|
||||||
|
{ id: 'MYR', name: 'Malaysian Ringgit' },
|
||||||
|
{ id: 'INR', name: 'Indian Rupee' },
|
||||||
|
{ id: 'KRW', name: 'South Korean Won' },
|
||||||
|
{ id: 'BRL', name: 'Brazilian Real' },
|
||||||
|
{ id: 'TRY', name: 'Turkish Lira' },
|
||||||
|
{ id: 'CZK', name: 'Czech Koruna' },
|
||||||
|
{ id: 'ILS', name: 'Israeli New Shekel' },
|
||||||
|
{ id: 'RUB', name: 'Russian Ruble' },
|
||||||
|
{ id: 'AED', name: 'United Arab Emirates Dirham' },
|
||||||
|
{ id: 'IDR', name: 'Indonesian Rupiah' },
|
||||||
|
{ id: 'PHP', name: 'Philippine Peso' },
|
||||||
|
{ id: 'RON', name: 'Romanian Leu' },
|
||||||
|
{ id: 'COP', name: 'Colombian Peso' },
|
||||||
|
{ id: 'SAR', name: 'Saudi Riyal' },
|
||||||
|
{ id: 'ARS', name: 'Argentine Peso' },
|
||||||
|
{ id: 'VND', name: 'Vietnamese Dong' },
|
||||||
|
{ id: 'CLP', name: 'Chilean Peso' },
|
||||||
|
{ id: 'EGP', name: 'Egyptian Pound' },
|
||||||
|
{ id: 'KWD', name: 'Kuwaiti Dinar' },
|
||||||
|
{ id: 'PKR', name: 'Pakistani Rupee' },
|
||||||
|
{ id: 'QAR', name: 'Qatari Riyal' },
|
||||||
|
{ id: 'BHD', name: 'Bahraini Dinar' },
|
||||||
|
{ id: 'UAH', name: 'Ukrainian Hryvnia' },
|
||||||
|
{ id: 'PEN', name: 'Peruvian Sol' },
|
||||||
|
{ id: 'BDT', name: 'Bangladeshi Taka' },
|
||||||
|
{ id: 'MAD', name: 'Moroccan Dirham' },
|
||||||
|
{ id: 'KES', name: 'Kenyan Shilling' },
|
||||||
|
{ id: 'NGN', name: 'Nigerian Naira' },
|
||||||
|
{ id: 'TND', name: 'Tunisian Dinar' },
|
||||||
|
{ id: 'OMR', name: 'Omani Rial' },
|
||||||
|
{ id: 'GHS', name: 'Ghanaian Cedi' },
|
||||||
|
];
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,26 @@ export const journeyReportSchema = z.object({
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const retentionReportSchema = z.object({
|
||||||
|
type: z.literal('retention'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const utmReportSchema = z.object({
|
||||||
|
type: z.literal('utm'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const revenueReportSchema = z.object({
|
||||||
|
type: z.literal('revenue'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const attributionReportSchema = z.object({
|
||||||
|
type: z.literal('attribution'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const insightsReportSchema = z.object({
|
||||||
|
type: z.literal('insights'),
|
||||||
|
});
|
||||||
|
|
||||||
export const reportBaseSchema = z.object({
|
export const reportBaseSchema = z.object({
|
||||||
websiteId: z.string().uuid(),
|
websiteId: z.string().uuid(),
|
||||||
type: reportTypeParam,
|
type: reportTypeParam,
|
||||||
|
|
@ -130,6 +150,11 @@ export const reportTypeSchema = z.discriminatedUnion('type', [
|
||||||
goalReportSchema,
|
goalReportSchema,
|
||||||
funnelReportSchema,
|
funnelReportSchema,
|
||||||
journeyReportSchema,
|
journeyReportSchema,
|
||||||
|
retentionReportSchema,
|
||||||
|
utmReportSchema,
|
||||||
|
revenueReportSchema,
|
||||||
|
attributionReportSchema,
|
||||||
|
insightsReportSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const reportSchema = z.intersection(reportBaseSchema, reportTypeSchema);
|
export const reportSchema = z.intersection(reportBaseSchema, reportTypeSchema);
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,12 @@ import clickhouse from '@/lib/clickhouse';
|
||||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
export async function getRetention(
|
export interface RetentionCriteria {
|
||||||
...args: [
|
|
||||||
websiteId: string,
|
|
||||||
filters: {
|
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
timezone?: string;
|
}
|
||||||
},
|
|
||||||
]
|
export async function getRetention(...args: [websiteId: string, criteria: RetentionCriteria]) {
|
||||||
) {
|
|
||||||
return runQuery({
|
return runQuery({
|
||||||
[PRISMA]: () => relationalQuery(...args),
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||||
|
|
@ -20,11 +16,7 @@ export async function getRetention(
|
||||||
|
|
||||||
async function relationalQuery(
|
async function relationalQuery(
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
filters: {
|
criteria: RetentionCriteria,
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
timezone?: string;
|
|
||||||
},
|
|
||||||
): Promise<
|
): Promise<
|
||||||
{
|
{
|
||||||
date: string;
|
date: string;
|
||||||
|
|
@ -34,7 +26,7 @@ async function relationalQuery(
|
||||||
percentage: number;
|
percentage: number;
|
||||||
}[]
|
}[]
|
||||||
> {
|
> {
|
||||||
const { startDate, endDate, timezone = 'UTC' } = filters;
|
const { startDate, endDate } = criteria;
|
||||||
const { getDateSQL, getDayDiffQuery, getCastColumnQuery, rawQuery } = prisma;
|
const { getDateSQL, getDayDiffQuery, getCastColumnQuery, rawQuery } = prisma;
|
||||||
const unit = 'day';
|
const unit = 'day';
|
||||||
|
|
||||||
|
|
@ -42,7 +34,7 @@ async function relationalQuery(
|
||||||
`
|
`
|
||||||
WITH cohort_items AS (
|
WITH cohort_items AS (
|
||||||
select session_id,
|
select session_id,
|
||||||
${getDateSQL('created_at', unit, timezone)} as cohort_date
|
${getDateSQL('created_at', unit)} as cohort_date
|
||||||
from session
|
from session
|
||||||
where website_id = {{websiteId::uuid}}
|
where website_id = {{websiteId::uuid}}
|
||||||
and created_at between {{startDate}} and {{endDate}}
|
and created_at between {{startDate}} and {{endDate}}
|
||||||
|
|
@ -50,7 +42,7 @@ async function relationalQuery(
|
||||||
user_activities AS (
|
user_activities AS (
|
||||||
select distinct
|
select distinct
|
||||||
w.session_id,
|
w.session_id,
|
||||||
${getDayDiffQuery(getDateSQL('created_at', unit, timezone), 'c.cohort_date')} as day_number
|
${getDayDiffQuery(getDateSQL('created_at', unit), 'c.cohort_date')} as day_number
|
||||||
from website_event w
|
from website_event w
|
||||||
join cohort_items c
|
join cohort_items c
|
||||||
on w.session_id = c.session_id
|
on w.session_id = c.session_id
|
||||||
|
|
@ -95,11 +87,7 @@ async function relationalQuery(
|
||||||
|
|
||||||
async function clickhouseQuery(
|
async function clickhouseQuery(
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
filters: {
|
criteria: RetentionCriteria,
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
timezone?: string;
|
|
||||||
},
|
|
||||||
): Promise<
|
): Promise<
|
||||||
{
|
{
|
||||||
date: string;
|
date: string;
|
||||||
|
|
@ -109,7 +97,7 @@ async function clickhouseQuery(
|
||||||
percentage: number;
|
percentage: number;
|
||||||
}[]
|
}[]
|
||||||
> {
|
> {
|
||||||
const { startDate, endDate, timezone = 'UTC' } = filters;
|
const { startDate, endDate } = criteria;
|
||||||
const { getDateSQL, rawQuery } = clickhouse;
|
const { getDateSQL, rawQuery } = clickhouse;
|
||||||
const unit = 'day';
|
const unit = 'day';
|
||||||
|
|
||||||
|
|
@ -117,7 +105,7 @@ async function clickhouseQuery(
|
||||||
`
|
`
|
||||||
WITH cohort_items AS (
|
WITH cohort_items AS (
|
||||||
select
|
select
|
||||||
min(${getDateSQL('created_at', unit, timezone)}) as cohort_date,
|
min(${getDateSQL('created_at', unit)}) as cohort_date,
|
||||||
session_id
|
session_id
|
||||||
from website_event
|
from website_event
|
||||||
where website_id = {websiteId:UUID}
|
where website_id = {websiteId:UUID}
|
||||||
|
|
@ -127,7 +115,7 @@ async function clickhouseQuery(
|
||||||
user_activities AS (
|
user_activities AS (
|
||||||
select distinct
|
select distinct
|
||||||
w.session_id,
|
w.session_id,
|
||||||
(${getDateSQL('created_at', unit, timezone)} - c.cohort_date) / 86400 as day_number
|
(${getDateSQL('created_at', unit)} - c.cohort_date) / 86400 as day_number
|
||||||
from website_event w
|
from website_event w
|
||||||
join cohort_items c
|
join cohort_items c
|
||||||
on w.session_id = c.session_id
|
on w.session_id = c.session_id
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,14 @@ import clickhouse from '@/lib/clickhouse';
|
||||||
import { CLICKHOUSE, getDatabaseType, POSTGRESQL, PRISMA, runQuery } from '@/lib/db';
|
import { CLICKHOUSE, getDatabaseType, POSTGRESQL, PRISMA, runQuery } from '@/lib/db';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
export async function getRevenue(
|
export interface RevenueCriteria {
|
||||||
...args: [
|
|
||||||
websiteId: string,
|
|
||||||
criteria: {
|
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
unit: string;
|
unit: string;
|
||||||
timezone: string;
|
|
||||||
currency: string;
|
currency: string;
|
||||||
},
|
}
|
||||||
]
|
|
||||||
) {
|
export async function getRevenue(...args: [websiteId: string, criteria: RevenueCriteria]) {
|
||||||
return runQuery({
|
return runQuery({
|
||||||
[PRISMA]: () => relationalQuery(...args),
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||||
|
|
@ -22,13 +18,7 @@ export async function getRevenue(
|
||||||
|
|
||||||
async function relationalQuery(
|
async function relationalQuery(
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
criteria: {
|
criteria: RevenueCriteria,
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
unit: string;
|
|
||||||
timezone: string;
|
|
||||||
currency: string;
|
|
||||||
},
|
|
||||||
): Promise<{
|
): Promise<{
|
||||||
chart: { x: string; t: string; y: number }[];
|
chart: { x: string; t: string; y: number }[];
|
||||||
country: { name: string; value: number }[];
|
country: { name: string; value: number }[];
|
||||||
|
|
@ -40,7 +30,7 @@ async function relationalQuery(
|
||||||
unique_count: number;
|
unique_count: number;
|
||||||
}[];
|
}[];
|
||||||
}> {
|
}> {
|
||||||
const { startDate, endDate, timezone = 'UTC', unit = 'day', currency } = criteria;
|
const { startDate, endDate, unit = 'day', currency } = criteria;
|
||||||
const { getDateSQL, rawQuery } = prisma;
|
const { getDateSQL, rawQuery } = prisma;
|
||||||
const db = getDatabaseType();
|
const db = getDatabaseType();
|
||||||
const like = db === POSTGRESQL ? 'ilike' : 'like';
|
const like = db === POSTGRESQL ? 'ilike' : 'like';
|
||||||
|
|
@ -49,7 +39,7 @@ async function relationalQuery(
|
||||||
`
|
`
|
||||||
select
|
select
|
||||||
we.event_name x,
|
we.event_name x,
|
||||||
${getDateSQL('ed.created_at', unit, timezone)} t,
|
${getDateSQL('ed.created_at', unit)} t,
|
||||||
sum(coalesce(cast(number_value as decimal(10,2)), cast(string_value as decimal(10,2)))) y
|
sum(coalesce(cast(number_value as decimal(10,2)), cast(string_value as decimal(10,2)))) y
|
||||||
from event_data ed
|
from event_data ed
|
||||||
join website_event we
|
join website_event we
|
||||||
|
|
@ -67,7 +57,7 @@ async function relationalQuery(
|
||||||
group by x, t
|
group by x, t
|
||||||
order by t
|
order by t
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, unit, timezone, currency },
|
{ websiteId, startDate, endDate, unit, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const countryRes = await rawQuery(
|
const countryRes = await rawQuery(
|
||||||
|
|
@ -140,7 +130,7 @@ async function relationalQuery(
|
||||||
group by c.currency
|
group by c.currency
|
||||||
order by sum desc;
|
order by sum desc;
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, unit, timezone, currency },
|
{ websiteId, startDate, endDate, unit, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
return { chart: chartRes, country: countryRes, total: totalRes, table: tableRes };
|
return { chart: chartRes, country: countryRes, total: totalRes, table: tableRes };
|
||||||
|
|
@ -148,13 +138,7 @@ async function relationalQuery(
|
||||||
|
|
||||||
async function clickhouseQuery(
|
async function clickhouseQuery(
|
||||||
websiteId: string,
|
websiteId: string,
|
||||||
criteria: {
|
criteria: RevenueCriteria,
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
unit: string;
|
|
||||||
timezone: string;
|
|
||||||
currency: string;
|
|
||||||
},
|
|
||||||
): Promise<{
|
): Promise<{
|
||||||
chart: { x: string; t: string; y: number }[];
|
chart: { x: string; t: string; y: number }[];
|
||||||
country: { name: string; value: number }[];
|
country: { name: string; value: number }[];
|
||||||
|
|
@ -166,7 +150,7 @@ async function clickhouseQuery(
|
||||||
unique_count: number;
|
unique_count: number;
|
||||||
}[];
|
}[];
|
||||||
}> {
|
}> {
|
||||||
const { startDate, endDate, timezone = 'UTC', unit = 'day', currency } = criteria;
|
const { startDate, endDate, unit = 'day', currency } = criteria;
|
||||||
const { getDateSQL, rawQuery } = clickhouse;
|
const { getDateSQL, rawQuery } = clickhouse;
|
||||||
|
|
||||||
const chartRes = await rawQuery<
|
const chartRes = await rawQuery<
|
||||||
|
|
@ -179,7 +163,7 @@ async function clickhouseQuery(
|
||||||
`
|
`
|
||||||
select
|
select
|
||||||
event_name x,
|
event_name x,
|
||||||
${getDateSQL('created_at', unit, timezone)} t,
|
${getDateSQL('created_at', unit)} t,
|
||||||
sum(coalesce(toDecimal64(number_value, 2), toDecimal64(string_value, 2))) y
|
sum(coalesce(toDecimal64(number_value, 2), toDecimal64(string_value, 2))) y
|
||||||
from event_data
|
from event_data
|
||||||
join (select event_id
|
join (select event_id
|
||||||
|
|
@ -195,7 +179,7 @@ async function clickhouseQuery(
|
||||||
group by x, t
|
group by x, t
|
||||||
order by t
|
order by t
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, unit, timezone, currency },
|
{ websiteId, startDate, endDate, unit, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
const countryRes = await rawQuery<
|
const countryRes = await rawQuery<
|
||||||
|
|
@ -283,7 +267,7 @@ async function clickhouseQuery(
|
||||||
group by c.currency
|
group by c.currency
|
||||||
order by sum desc;
|
order by sum desc;
|
||||||
`,
|
`,
|
||||||
{ websiteId, startDate, endDate, unit, timezone, currency },
|
{ websiteId, startDate, endDate, unit, currency },
|
||||||
);
|
);
|
||||||
|
|
||||||
return { chart: chartRes, country: countryRes, total: totalRes, table: tableRes };
|
return { chart: chartRes, country: countryRes, total: totalRes, table: tableRes };
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import prisma from '@/lib/prisma';
|
|
||||||
import clickhouse from '@/lib/clickhouse';
|
|
||||||
import { runQuery, CLICKHOUSE, PRISMA, getDatabaseType, POSTGRESQL } from '@/lib/db';
|
|
||||||
|
|
||||||
export async function getRevenueValues(
|
|
||||||
...args: [
|
|
||||||
websiteId: string,
|
|
||||||
criteria: {
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
},
|
|
||||||
]
|
|
||||||
) {
|
|
||||||
return runQuery({
|
|
||||||
[PRISMA]: () => relationalQuery(...args),
|
|
||||||
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function relationalQuery(
|
|
||||||
websiteId: string,
|
|
||||||
criteria: {
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const { rawQuery } = prisma;
|
|
||||||
const { startDate, endDate } = criteria;
|
|
||||||
|
|
||||||
const db = getDatabaseType();
|
|
||||||
const like = db === POSTGRESQL ? 'ilike' : 'like';
|
|
||||||
|
|
||||||
return rawQuery(
|
|
||||||
`
|
|
||||||
select distinct string_value as currency
|
|
||||||
from event_data
|
|
||||||
where website_id = {{websiteId::uuid}}
|
|
||||||
and created_at between {{startDate}} and {{endDate}}
|
|
||||||
and data_key ${like} '%currency%'
|
|
||||||
order by currency
|
|
||||||
`,
|
|
||||||
{
|
|
||||||
websiteId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clickhouseQuery(
|
|
||||||
websiteId: string,
|
|
||||||
criteria: {
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const { rawQuery } = clickhouse;
|
|
||||||
const { startDate, endDate } = criteria;
|
|
||||||
|
|
||||||
return rawQuery(
|
|
||||||
`
|
|
||||||
select distinct string_value as currency
|
|
||||||
from event_data
|
|
||||||
where website_id = {websiteId:UUID}
|
|
||||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
|
||||||
and positionCaseInsensitive(data_key, 'currency') > 0
|
|
||||||
order by currency
|
|
||||||
`,
|
|
||||||
{
|
|
||||||
websiteId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -2,31 +2,20 @@ import clickhouse from '@/lib/clickhouse';
|
||||||
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db';
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
export async function getUTM(
|
export interface UTMCriteria {
|
||||||
...args: [
|
|
||||||
websiteId: string,
|
|
||||||
filters: {
|
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
timezone?: string;
|
}
|
||||||
},
|
|
||||||
]
|
export async function getUTM(...args: [websiteId: string, criteria: UTMCriteria]) {
|
||||||
) {
|
|
||||||
return runQuery({
|
return runQuery({
|
||||||
[PRISMA]: () => relationalQuery(...args),
|
[PRISMA]: () => relationalQuery(...args),
|
||||||
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function relationalQuery(
|
async function relationalQuery(websiteId: string, criteria: UTMCriteria) {
|
||||||
websiteId: string,
|
const { startDate, endDate } = criteria;
|
||||||
filters: {
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
timezone?: string;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const { startDate, endDate } = filters;
|
|
||||||
const { rawQuery } = prisma;
|
const { rawQuery } = prisma;
|
||||||
|
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
|
|
@ -47,15 +36,8 @@ async function relationalQuery(
|
||||||
).then(result => parseParameters(result as any[]));
|
).then(result => parseParameters(result as any[]));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clickhouseQuery(
|
async function clickhouseQuery(websiteId: string, criteria: UTMCriteria) {
|
||||||
websiteId: string,
|
const { startDate, endDate } = criteria;
|
||||||
filters: {
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
timezone?: string;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const { startDate, endDate } = filters;
|
|
||||||
const { rawQuery } = clickhouse;
|
const { rawQuery } = clickhouse;
|
||||||
|
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue