mirror of
https://github.com/umami-software/umami.git
synced 2025-12-08 05:12:36 +01:00
Merge branch 'dev' of https://github.com/umami-software/umami into dev
This commit is contained in:
commit
6c832bd0db
113 changed files with 1671 additions and 1335 deletions
|
|
@ -13,7 +13,7 @@ import {
|
|||
LayoutDashboard,
|
||||
Link as LinkIcon,
|
||||
Logo,
|
||||
Grid2X2,
|
||||
Pixel,
|
||||
Settings,
|
||||
PanelLeft,
|
||||
} from '@/components/icons';
|
||||
|
|
@ -21,6 +21,7 @@ import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
|
|||
import { TeamsButton } from '@/components/input/TeamsButton';
|
||||
import { PanelButton } from '@/components/input/PanelButton';
|
||||
import { ProfileButton } from '@/components/input/ProfileButton';
|
||||
import { LanguageButton } from '@/components/input/LanguageButton';
|
||||
|
||||
export function SideNav(props: SidebarProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
|
@ -52,7 +53,7 @@ export function SideNav(props: SidebarProps) {
|
|||
id: 'pixels',
|
||||
label: formatMessage(labels.pixels),
|
||||
path: '/pixels',
|
||||
icon: <Grid2X2 />,
|
||||
icon: <Pixel />,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -97,6 +98,7 @@ export function SideNav(props: SidebarProps) {
|
|||
<ProfileButton />
|
||||
{!isCollapsed && !hasNav && (
|
||||
<Row>
|
||||
<LanguageButton />
|
||||
<ThemeButton />
|
||||
</Row>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
import { TeamSettings } from '@/app/(main)/teams/[teamId]/TeamSettings';
|
||||
import { TeamProvider } from '@/app/(main)/teams/[teamId]/TeamProvider';
|
||||
import { TeamProvider } from '@/app/(main)/teams/TeamProvider';
|
||||
|
||||
export function AdminTeamPage({ teamId }: { teamId: string }) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -9,15 +9,13 @@ import {
|
|||
PasswordField,
|
||||
useToast,
|
||||
} from '@umami/react-zen';
|
||||
import { useApi, useLoginQuery, useMessages, useModified } from '@/components/hooks';
|
||||
import { useApi, useLoginQuery, useMessages, useModified, useUser } from '@/components/hooks';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
import { useContext } from 'react';
|
||||
import { UserContext } from './UserProvider';
|
||||
|
||||
export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) {
|
||||
const { formatMessage, labels, messages, getMessage } = useMessages();
|
||||
const { post, useMutation } = useApi();
|
||||
const user = useContext(UserContext);
|
||||
const user = useUser();
|
||||
const { user: login } = useLoginQuery();
|
||||
const { toast } = useToast();
|
||||
const { touch } = useModified();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { useContext } from 'react';
|
||||
import { User } from '@/components/icons';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { UserContext } from '@/app/(main)/admin/users/[userId]/UserProvider';
|
||||
import { useUser } from '@/components/hooks';
|
||||
|
||||
export function UserHeader() {
|
||||
const user = useContext(UserContext);
|
||||
const user = useUser();
|
||||
|
||||
return <PageHeader title={user?.username} icon={<User />} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
import { WebsiteSettings } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettings';
|
||||
import { WebsiteProvider } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
||||
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
|
||||
export function AdminWebsitePage({ websiteId }: { websiteId: string }) {
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ export function LinkEditForm({
|
|||
onSave?: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { mutate, error, isPending, touch } = useUpdateQuery(
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { mutate, error, isPending, touch, toast } = useUpdateQuery(
|
||||
linkId ? `/links/${linkId}` : '/links',
|
||||
{
|
||||
id: linkId,
|
||||
|
|
@ -48,6 +48,7 @@ export function LinkEditForm({
|
|||
const handleSubmit = async (data: any) => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
toast(formatMessage(messages.saved));
|
||||
touch('links');
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
|
|
|
|||
20
src/app/(main)/links/LinkProvider.tsx
Normal file
20
src/app/(main)/links/LinkProvider.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
'use client';
|
||||
import { createContext, ReactNode } from 'react';
|
||||
import { useLinkQuery } from '@/components/hooks';
|
||||
import { Loading } from '@umami/react-zen';
|
||||
|
||||
export const LinkContext = createContext(null);
|
||||
|
||||
export function LinkProvider({ linkId, children }: { linkId?: string; children: ReactNode }) {
|
||||
const { data: link, isLoading, isFetching } = useLinkQuery(linkId);
|
||||
|
||||
if (isFetching && isLoading) {
|
||||
return <Loading position="page" />;
|
||||
}
|
||||
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <LinkContext.Provider value={link}>{children}</LinkContext.Provider>;
|
||||
}
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
import Link from 'next/link';
|
||||
import { DataTable, DataColumn, Row } from '@umami/react-zen';
|
||||
import { useConfig, useMessages, useNavigation } from '@/components/hooks';
|
||||
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { ExternalLink } from '@/components/common/ExternalLink';
|
||||
import { LinkEditButton } from './LinkEditButton';
|
||||
import { LinkDeleteButton } from './LinkDeleteButton';
|
||||
import { LINKS_URL } from '@/lib/constants';
|
||||
|
||||
export function LinksTable({ data = [] }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { websiteId } = useNavigation();
|
||||
const { linksUrl } = useConfig();
|
||||
const hostUrl = linksUrl || LINKS_URL;
|
||||
const { websiteId, renderUrl } = useNavigation();
|
||||
const { getSlugUrl } = useSlug('link');
|
||||
|
||||
if (data.length === 0) {
|
||||
return <Empty />;
|
||||
|
|
@ -19,10 +18,14 @@ export function LinksTable({ data = [] }) {
|
|||
|
||||
return (
|
||||
<DataTable data={data}>
|
||||
<DataColumn id="name" label={formatMessage(labels.name)} />
|
||||
<DataColumn id="name" label={formatMessage(labels.name)}>
|
||||
{({ id, name }: any) => {
|
||||
return <Link href={renderUrl(`/links/${id}`)}>{name}</Link>;
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="slug" label={formatMessage(labels.link)}>
|
||||
{({ slug }: any) => {
|
||||
const url = `${hostUrl}/${slug}`;
|
||||
const url = getSlugUrl(slug);
|
||||
return <ExternalLink href={url}>{url}</ExternalLink>;
|
||||
}}
|
||||
</DataColumn>
|
||||
|
|
|
|||
32
src/app/(main)/links/[linkId]/LinkControls.tsx
Normal file
32
src/app/(main)/links/[linkId]/LinkControls.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Column, Row } from '@umami/react-zen';
|
||||
import { WebsiteFilterButton } from '@/app/(main)/websites/[websiteId]/WebsiteFilterButton';
|
||||
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
|
||||
import { FilterBar } from '@/components/input/FilterBar';
|
||||
import { WebsiteMonthSelect } from '@/components/input/WebsiteMonthSelect';
|
||||
import { ExportButton } from '@/components/input/ExportButton';
|
||||
|
||||
export function LinkControls({
|
||||
linkId: websiteId,
|
||||
allowFilter = true,
|
||||
allowDateFilter = true,
|
||||
allowMonthFilter,
|
||||
allowDownload = false,
|
||||
}: {
|
||||
linkId: string;
|
||||
allowFilter?: boolean;
|
||||
allowDateFilter?: boolean;
|
||||
allowMonthFilter?: boolean;
|
||||
allowDownload?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Column gap>
|
||||
<Row alignItems="center" justifyContent="space-between" gap="3">
|
||||
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
|
||||
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} showAllTime={false} />}
|
||||
{allowDownload && <ExportButton websiteId={websiteId} />}
|
||||
{allowMonthFilter && <WebsiteMonthSelect websiteId={websiteId} />}
|
||||
</Row>
|
||||
{allowFilter && <FilterBar websiteId={websiteId} />}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
22
src/app/(main)/links/[linkId]/LinkHeader.tsx
Normal file
22
src/app/(main)/links/[linkId]/LinkHeader.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { useLink, useMessages, useSlug } from '@/components/hooks';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { Icon, Text } from '@umami/react-zen';
|
||||
import { ExternalLink, Link } from '@/components/icons';
|
||||
import { LinkButton } from '@/components/common/LinkButton';
|
||||
|
||||
export function LinkHeader() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { getSlugUrl } = useSlug('link');
|
||||
const link = useLink();
|
||||
|
||||
return (
|
||||
<PageHeader title={link.name} description={link.url} icon={<Link />}>
|
||||
<LinkButton href={getSlugUrl(link.slug)} target="_blank">
|
||||
<Icon>
|
||||
<ExternalLink />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.view)}</Text>
|
||||
</LinkButton>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
71
src/app/(main)/links/[linkId]/LinkMetricsBar.tsx
Normal file
71
src/app/(main)/links/[linkId]/LinkMetricsBar.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useDateRange, useMessages } from '@/components/hooks';
|
||||
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
|
||||
export function LinkMetricsBar({
|
||||
linkId,
|
||||
}: {
|
||||
linkId: string;
|
||||
showChange?: boolean;
|
||||
compareMode?: boolean;
|
||||
}) {
|
||||
const { dateRange } = useDateRange(linkId);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(linkId);
|
||||
const isAllTime = dateRange.value === 'all';
|
||||
|
||||
const { pageviews, visitors, visits, comparison } = data || {};
|
||||
|
||||
const metrics = data
|
||||
? [
|
||||
{
|
||||
value: visitors,
|
||||
label: formatMessage(labels.visitors),
|
||||
change: visitors - comparison.visitors,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
{
|
||||
value: visits,
|
||||
label: formatMessage(labels.visits),
|
||||
change: visits - comparison.visits,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
{
|
||||
value: pageviews,
|
||||
label: formatMessage(labels.views),
|
||||
change: pageviews - comparison.pageviews,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<LoadingPanel
|
||||
data={metrics}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
minHeight="136px"
|
||||
>
|
||||
<MetricsBar>
|
||||
{metrics?.map(({ label, value, prev, change, formatValue, reverseColors }: any) => {
|
||||
return (
|
||||
<MetricCard
|
||||
key={label}
|
||||
value={value}
|
||||
previousValue={prev}
|
||||
label={label}
|
||||
change={change}
|
||||
formatValue={formatValue}
|
||||
reverseColors={reverseColors}
|
||||
showChange={!isAllTime}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MetricsBar>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
25
src/app/(main)/links/[linkId]/LinkPage.tsx
Normal file
25
src/app/(main)/links/[linkId]/LinkPage.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
'use client';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { LinkProvider } from '@/app/(main)/links/LinkProvider';
|
||||
import { LinkHeader } from '@/app/(main)/links/[linkId]/LinkHeader';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
|
||||
import { LinkMetricsBar } from '@/app/(main)/links/[linkId]/LinkMetricsBar';
|
||||
import { LinkControls } from '@/app/(main)/links/[linkId]/LinkControls';
|
||||
import { LinkPanels } from '@/app/(main)/links/[linkId]/LinkPanels';
|
||||
|
||||
export function LinkPage({ linkId }: { linkId: string }) {
|
||||
return (
|
||||
<LinkProvider linkId={linkId}>
|
||||
<PageBody gap>
|
||||
<LinkHeader />
|
||||
<LinkControls linkId={linkId} />
|
||||
<LinkMetricsBar linkId={linkId} showChange={true} />
|
||||
<Panel>
|
||||
<WebsiteChart websiteId={linkId} />
|
||||
</Panel>
|
||||
<LinkPanels linkId={linkId} />
|
||||
</PageBody>
|
||||
</LinkProvider>
|
||||
);
|
||||
}
|
||||
83
src/app/(main)/links/[linkId]/LinkPanels.tsx
Normal file
83
src/app/(main)/links/[linkId]/LinkPanels.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Grid, Tabs, Tab, TabList, TabPanel, Heading } from '@umami/react-zen';
|
||||
import { GridRow } from '@/components/common/GridRow';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { WorldMap } from '@/components/metrics/WorldMap';
|
||||
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export function LinkPanels({ linkId }: { linkId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const tableProps = {
|
||||
websiteId: linkId,
|
||||
limit: 10,
|
||||
allowDownload: false,
|
||||
showMore: true,
|
||||
metric: formatMessage(labels.visitors),
|
||||
};
|
||||
const rowProps = { minHeight: 570 };
|
||||
|
||||
return (
|
||||
<Grid gap="3">
|
||||
<GridRow layout="two" {...rowProps}>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(labels.sources)}</Heading>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
|
||||
<Tab id="channel">{formatMessage(labels.channels)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="referrer">
|
||||
<MetricsTable type="referrer" title={formatMessage(labels.domain)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="channel">
|
||||
<MetricsTable type="channel" title={formatMessage(labels.type)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(labels.environment)}</Heading>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="browser">{formatMessage(labels.browsers)}</Tab>
|
||||
<Tab id="os">{formatMessage(labels.os)}</Tab>
|
||||
<Tab id="device">{formatMessage(labels.devices)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="browser">
|
||||
<MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="os">
|
||||
<MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="device">
|
||||
<MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
</GridRow>
|
||||
<GridRow layout="two" {...rowProps}>
|
||||
<Panel noPadding>
|
||||
<WorldMap websiteId={linkId} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(labels.location)}</Heading>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="country">{formatMessage(labels.countries)}</Tab>
|
||||
<Tab id="region">{formatMessage(labels.regions)}</Tab>
|
||||
<Tab id="city">{formatMessage(labels.cities)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="country">
|
||||
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="region">
|
||||
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="city">
|
||||
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
</GridRow>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
12
src/app/(main)/links/[linkId]/page.tsx
Normal file
12
src/app/(main)/links/[linkId]/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { LinkPage } from './LinkPage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default async function ({ params }: { params: Promise<{ linkId: string }> }) {
|
||||
const { linkId } = await params;
|
||||
|
||||
return <LinkPage linkId={linkId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Link',
|
||||
};
|
||||
|
|
@ -31,8 +31,8 @@ export function PixelEditForm({
|
|||
onSave?: () => void;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { mutate, error, isPending, touch } = useUpdateQuery(
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { mutate, error, isPending, touch, toast } = useUpdateQuery(
|
||||
pixelId ? `/pixels/${pixelId}` : '/pixels',
|
||||
{
|
||||
id: pixelId,
|
||||
|
|
@ -47,6 +47,7 @@ export function PixelEditForm({
|
|||
const handleSubmit = async (data: any) => {
|
||||
mutate(data, {
|
||||
onSuccess: async () => {
|
||||
toast(formatMessage(messages.saved));
|
||||
touch('pixels');
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
|
|
|
|||
20
src/app/(main)/pixels/PixelProvider.tsx
Normal file
20
src/app/(main)/pixels/PixelProvider.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
'use client';
|
||||
import { createContext, ReactNode } from 'react';
|
||||
import { usePixelQuery } from '@/components/hooks';
|
||||
import { Loading } from '@umami/react-zen';
|
||||
|
||||
export const PixelContext = createContext(null);
|
||||
|
||||
export function PixelProvider({ pixelId, children }: { pixelId?: string; children: ReactNode }) {
|
||||
const { data: pixel, isLoading, isFetching } = usePixelQuery(pixelId);
|
||||
|
||||
if (isFetching && isLoading) {
|
||||
return <Loading position="page" />;
|
||||
}
|
||||
|
||||
if (!pixel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <PixelContext.Provider value={pixel}>{children}</PixelContext.Provider>;
|
||||
}
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
import Link from 'next/link';
|
||||
import { DataTable, DataColumn, Row } from '@umami/react-zen';
|
||||
import { useConfig, useMessages } from '@/components/hooks';
|
||||
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { DateDistance } from '@/components/common/DateDistance';
|
||||
import { PixelEditButton } from './PixelEditButton';
|
||||
import { PixelDeleteButton } from './PixelDeleteButton';
|
||||
import { PIXELS_URL } from '@/lib/constants';
|
||||
import { ExternalLink } from '@/components/common/ExternalLink';
|
||||
|
||||
export function PixelsTable({ data = [] }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { pixelsUrl } = useConfig();
|
||||
const hostUrl = pixelsUrl || PIXELS_URL;
|
||||
const { renderUrl } = useNavigation();
|
||||
const { getSlugUrl } = useSlug('pixel');
|
||||
|
||||
if (data.length === 0) {
|
||||
return <Empty />;
|
||||
|
|
@ -18,10 +18,14 @@ export function PixelsTable({ data = [] }) {
|
|||
|
||||
return (
|
||||
<DataTable data={data}>
|
||||
<DataColumn id="name" label={formatMessage(labels.name)} />
|
||||
<DataColumn id="name" label={formatMessage(labels.name)}>
|
||||
{({ id, name }: any) => {
|
||||
return <Link href={renderUrl(`/pixels/${id}`)}>{name}</Link>;
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn id="url" label="URL">
|
||||
{({ slug }: any) => {
|
||||
const url = `${hostUrl}/${slug}`;
|
||||
const url = getSlugUrl(slug);
|
||||
return <ExternalLink href={url}>{url}</ExternalLink>;
|
||||
}}
|
||||
</DataColumn>
|
||||
|
|
|
|||
32
src/app/(main)/pixels/[pixelId]/PixelControls.tsx
Normal file
32
src/app/(main)/pixels/[pixelId]/PixelControls.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Column, Row } from '@umami/react-zen';
|
||||
import { WebsiteFilterButton } from '@/app/(main)/websites/[websiteId]/WebsiteFilterButton';
|
||||
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
|
||||
import { FilterBar } from '@/components/input/FilterBar';
|
||||
import { WebsiteMonthSelect } from '@/components/input/WebsiteMonthSelect';
|
||||
import { ExportButton } from '@/components/input/ExportButton';
|
||||
|
||||
export function PixelControls({
|
||||
pixelId: websiteId,
|
||||
allowFilter = true,
|
||||
allowDateFilter = true,
|
||||
allowMonthFilter,
|
||||
allowDownload = false,
|
||||
}: {
|
||||
pixelId: string;
|
||||
allowFilter?: boolean;
|
||||
allowDateFilter?: boolean;
|
||||
allowMonthFilter?: boolean;
|
||||
allowDownload?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Column gap>
|
||||
<Row alignItems="center" justifyContent="space-between" gap="3">
|
||||
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
|
||||
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} showAllTime={false} />}
|
||||
{allowDownload && <ExportButton websiteId={websiteId} />}
|
||||
{allowMonthFilter && <WebsiteMonthSelect websiteId={websiteId} />}
|
||||
</Row>
|
||||
{allowFilter && <FilterBar websiteId={websiteId} />}
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
22
src/app/(main)/pixels/[pixelId]/PixelHeader.tsx
Normal file
22
src/app/(main)/pixels/[pixelId]/PixelHeader.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { usePixel, useMessages, useSlug } from '@/components/hooks';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { Icon, Text } from '@umami/react-zen';
|
||||
import { ExternalLink, Pixel } from '@/components/icons';
|
||||
import { LinkButton } from '@/components/common/LinkButton';
|
||||
|
||||
export function PixelHeader() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { getSlugUrl } = useSlug('pixel');
|
||||
const pixel = usePixel();
|
||||
|
||||
return (
|
||||
<PageHeader title={pixel.name} description={pixel.url} icon={<Pixel />}>
|
||||
<LinkButton href={getSlugUrl(pixel.slug)} target="_blank">
|
||||
<Icon>
|
||||
<ExternalLink />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.view)}</Text>
|
||||
</LinkButton>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
71
src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx
Normal file
71
src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useDateRange, useMessages } from '@/components/hooks';
|
||||
import { MetricCard } from '@/components/metrics/MetricCard';
|
||||
import { MetricsBar } from '@/components/metrics/MetricsBar';
|
||||
import { formatLongNumber } from '@/lib/format';
|
||||
import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
|
||||
export function PixelMetricsBar({
|
||||
pixelId,
|
||||
}: {
|
||||
pixelId: string;
|
||||
showChange?: boolean;
|
||||
compareMode?: boolean;
|
||||
}) {
|
||||
const { dateRange } = useDateRange(pixelId);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(pixelId);
|
||||
const isAllTime = dateRange.value === 'all';
|
||||
|
||||
const { pageviews, visitors, visits, comparison } = data || {};
|
||||
|
||||
const metrics = data
|
||||
? [
|
||||
{
|
||||
value: visitors,
|
||||
label: formatMessage(labels.visitors),
|
||||
change: visitors - comparison.visitors,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
{
|
||||
value: visits,
|
||||
label: formatMessage(labels.visits),
|
||||
change: visits - comparison.visits,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
{
|
||||
value: pageviews,
|
||||
label: formatMessage(labels.views),
|
||||
change: pageviews - comparison.pageviews,
|
||||
formatValue: formatLongNumber,
|
||||
},
|
||||
]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<LoadingPanel
|
||||
data={metrics}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
minHeight="136px"
|
||||
>
|
||||
<MetricsBar>
|
||||
{metrics?.map(({ label, value, prev, change, formatValue, reverseColors }: any) => {
|
||||
return (
|
||||
<MetricCard
|
||||
key={label}
|
||||
value={value}
|
||||
previousValue={prev}
|
||||
label={label}
|
||||
change={change}
|
||||
formatValue={formatValue}
|
||||
reverseColors={reverseColors}
|
||||
showChange={!isAllTime}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</MetricsBar>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
25
src/app/(main)/pixels/[pixelId]/PixelPage.tsx
Normal file
25
src/app/(main)/pixels/[pixelId]/PixelPage.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
'use client';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { PixelProvider } from '@/app/(main)/pixels/PixelProvider';
|
||||
import { PixelHeader } from '@/app/(main)/pixels/[pixelId]/PixelHeader';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
|
||||
import { PixelMetricsBar } from '@/app/(main)/pixels/[pixelId]/PixelMetricsBar';
|
||||
import { PixelControls } from '@/app/(main)/pixels/[pixelId]/PixelControls';
|
||||
import { PixelPanels } from '@/app/(main)/pixels/[pixelId]/PixelPanels';
|
||||
|
||||
export function PixelPage({ pixelId }: { pixelId: string }) {
|
||||
return (
|
||||
<PixelProvider pixelId={pixelId}>
|
||||
<PageBody gap>
|
||||
<PixelHeader />
|
||||
<PixelControls pixelId={pixelId} />
|
||||
<PixelMetricsBar pixelId={pixelId} showChange={true} />
|
||||
<Panel>
|
||||
<WebsiteChart websiteId={pixelId} />
|
||||
</Panel>
|
||||
<PixelPanels pixelId={pixelId} />
|
||||
</PageBody>
|
||||
</PixelProvider>
|
||||
);
|
||||
}
|
||||
83
src/app/(main)/pixels/[pixelId]/PixelPanels.tsx
Normal file
83
src/app/(main)/pixels/[pixelId]/PixelPanels.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Grid, Tabs, Tab, TabList, TabPanel, Heading } from '@umami/react-zen';
|
||||
import { GridRow } from '@/components/common/GridRow';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { WorldMap } from '@/components/metrics/WorldMap';
|
||||
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export function PixelPanels({ pixelId }: { pixelId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const tableProps = {
|
||||
websiteId: pixelId,
|
||||
limit: 10,
|
||||
allowDownload: false,
|
||||
showMore: true,
|
||||
metric: formatMessage(labels.visitors),
|
||||
};
|
||||
const rowProps = { minHeight: 570 };
|
||||
|
||||
return (
|
||||
<Grid gap="3">
|
||||
<GridRow layout="two" {...rowProps}>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(labels.sources)}</Heading>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
|
||||
<Tab id="channel">{formatMessage(labels.channels)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="referrer">
|
||||
<MetricsTable type="referrer" title={formatMessage(labels.domain)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="channel">
|
||||
<MetricsTable type="channel" title={formatMessage(labels.type)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(labels.environment)}</Heading>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="browser">{formatMessage(labels.browsers)}</Tab>
|
||||
<Tab id="os">{formatMessage(labels.os)}</Tab>
|
||||
<Tab id="device">{formatMessage(labels.devices)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="browser">
|
||||
<MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="os">
|
||||
<MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="device">
|
||||
<MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
</GridRow>
|
||||
<GridRow layout="two" {...rowProps}>
|
||||
<Panel noPadding>
|
||||
<WorldMap websiteId={pixelId} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(labels.location)}</Heading>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="country">{formatMessage(labels.countries)}</Tab>
|
||||
<Tab id="region">{formatMessage(labels.regions)}</Tab>
|
||||
<Tab id="city">{formatMessage(labels.cities)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="country">
|
||||
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="region">
|
||||
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="city">
|
||||
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
</GridRow>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
12
src/app/(main)/pixels/[pixelId]/page.tsx
Normal file
12
src/app/(main)/pixels/[pixelId]/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { PixelPage } from './PixelPage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default async function ({ params }: { params: Promise<{ pixelId: string }> }) {
|
||||
const { pixelId } = await params;
|
||||
|
||||
return <PixelPage pixelId={pixelId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Pixel',
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
'use client';
|
||||
import { TeamProvider } from '@/app/(main)/teams/[teamId]/TeamProvider';
|
||||
import { TeamProvider } from '@/app/(main)/teams/TeamProvider';
|
||||
import { TeamSettings } from '@/app/(main)/teams/[teamId]/TeamSettings';
|
||||
|
||||
export function TeamSettingsPage({ teamId }: { teamId: string }) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { WebsiteProvider } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
||||
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
|
||||
import { WebsiteSettings } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettings';
|
||||
import { WebsiteSettingsHeader } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ import {
|
|||
useToast,
|
||||
} from '@umami/react-zen';
|
||||
import { getRandomChars } from '@/lib/crypto';
|
||||
import { useContext } from 'react';
|
||||
import { useApi, useMessages, useModified } from '@/components/hooks';
|
||||
import { TeamContext } from '@/app/(main)/teams/[teamId]/TeamProvider';
|
||||
import { useApi, useMessages, useModified, useTeam } from '@/components/hooks';
|
||||
|
||||
const generateId = () => `team_${getRandomChars(16)}`;
|
||||
|
||||
|
|
@ -23,7 +21,7 @@ export function TeamEditForm({
|
|||
allowEdit?: boolean;
|
||||
onSave?: () => void;
|
||||
}) {
|
||||
const team = useContext(TeamContext);
|
||||
const team = useTeam();
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { post, useMutation } = useApi();
|
||||
const { toast } = useToast();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useContext, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Column, Tabs, TabList, Tab, TabPanel } from '@umami/react-zen';
|
||||
import { TeamContext } from '@/app/(main)/teams/[teamId]/TeamProvider';
|
||||
import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
|
||||
import { useLoginQuery, useMessages, useNavigation, useTeam } from '@/components/hooks';
|
||||
|
||||
import { ROLES } from '@/lib/constants';
|
||||
import { Users } from '@/components/icons';
|
||||
|
|
@ -14,7 +13,7 @@ import { PageHeader } from '@/components/common/PageHeader';
|
|||
import { Panel } from '@/components/common/Panel';
|
||||
|
||||
export function TeamSettings({ teamId }: { teamId: string }) {
|
||||
const team = useContext(TeamContext);
|
||||
const team = useTeam();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { user } = useLoginQuery();
|
||||
const { query, pathname } = useNavigation();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export function UTMPage({ websiteId }: { websiteId: string }) {
|
|||
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} allowCompare={false} />
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<UTM websiteId={websiteId} startDate={startDate} endDate={endDate} />
|
||||
</Column>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export function WebsiteChart({
|
|||
compareMode,
|
||||
}: {
|
||||
websiteId: string;
|
||||
compareMode?: string;
|
||||
compareMode?: boolean;
|
||||
}) {
|
||||
const { dateRange, dateCompare } = useDateRange(websiteId);
|
||||
const { startDate, endDate, unit, value } = dateRange;
|
||||
|
|
|
|||
|
|
@ -1,183 +0,0 @@
|
|||
import { Grid, Heading, Column, Row, NavMenu, NavMenuItem, Text } from '@umami/react-zen';
|
||||
import { useDateRange, useMessages, useNavigation } from '@/components/hooks';
|
||||
import { BrowsersTable } from '@/components/metrics/BrowsersTable';
|
||||
import { ChangeLabel } from '@/components/metrics/ChangeLabel';
|
||||
import { CitiesTable } from '@/components/metrics/CitiesTable';
|
||||
import { CountriesTable } from '@/components/metrics/CountriesTable';
|
||||
import { DevicesTable } from '@/components/metrics/DevicesTable';
|
||||
import { EventsTable } from '@/components/metrics/EventsTable';
|
||||
import { LanguagesTable } from '@/components/metrics/LanguagesTable';
|
||||
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||
import { OSTable } from '@/components/metrics/OSTable';
|
||||
import { PagesTable } from '@/components/metrics/PagesTable';
|
||||
import { QueryParametersTable } from '@/components/metrics/QueryParametersTable';
|
||||
import { ReferrersTable } from '@/components/metrics/ReferrersTable';
|
||||
import { RegionsTable } from '@/components/metrics/RegionsTable';
|
||||
import { ScreenTable } from '@/components/metrics/ScreenTable';
|
||||
import { TagsTable } from '@/components/metrics/TagsTable';
|
||||
import { getCompareDate } from '@/lib/date';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { useState } from 'react';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { DateDisplay } from '@/components/common/DateDisplay';
|
||||
|
||||
const views = {
|
||||
path: PagesTable,
|
||||
title: PagesTable,
|
||||
referrer: ReferrersTable,
|
||||
browser: BrowsersTable,
|
||||
os: OSTable,
|
||||
device: DevicesTable,
|
||||
screen: ScreenTable,
|
||||
country: CountriesTable,
|
||||
region: RegionsTable,
|
||||
city: CitiesTable,
|
||||
language: LanguagesTable,
|
||||
event: EventsTable,
|
||||
query: QueryParametersTable,
|
||||
tag: TagsTable,
|
||||
};
|
||||
|
||||
export function WebsiteCompareTables({ websiteId }: { websiteId: string }) {
|
||||
const [data] = useState([]);
|
||||
const { dateRange, dateCompare } = useDateRange(websiteId);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const {
|
||||
updateParams,
|
||||
query: { view },
|
||||
} = useNavigation();
|
||||
const Component: typeof MetricsTable = views[view || 'path'] || (() => null);
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: 'path',
|
||||
label: formatMessage(labels.pages),
|
||||
path: updateParams({ view: 'path' }),
|
||||
},
|
||||
{
|
||||
id: 'referrer',
|
||||
label: formatMessage(labels.referrers),
|
||||
path: updateParams({ view: 'referrer' }),
|
||||
},
|
||||
{
|
||||
id: 'browser',
|
||||
label: formatMessage(labels.browsers),
|
||||
path: updateParams({ view: 'browser' }),
|
||||
},
|
||||
{
|
||||
id: 'os',
|
||||
label: formatMessage(labels.os),
|
||||
path: updateParams({ view: 'os' }),
|
||||
},
|
||||
{
|
||||
id: 'device',
|
||||
label: formatMessage(labels.devices),
|
||||
path: updateParams({ view: 'device' }),
|
||||
},
|
||||
{
|
||||
id: 'country',
|
||||
label: formatMessage(labels.countries),
|
||||
path: updateParams({ view: 'country' }),
|
||||
},
|
||||
{
|
||||
id: 'region',
|
||||
label: formatMessage(labels.regions),
|
||||
path: updateParams({ view: 'region' }),
|
||||
},
|
||||
{
|
||||
id: 'city',
|
||||
label: formatMessage(labels.cities),
|
||||
path: updateParams({ view: 'city' }),
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
label: formatMessage(labels.languages),
|
||||
path: updateParams({ view: 'language' }),
|
||||
},
|
||||
{
|
||||
id: 'screen',
|
||||
label: formatMessage(labels.screens),
|
||||
path: updateParams({ view: 'screen' }),
|
||||
},
|
||||
{
|
||||
id: 'event',
|
||||
label: formatMessage(labels.events),
|
||||
path: updateParams({ view: 'event' }),
|
||||
},
|
||||
{
|
||||
id: 'query',
|
||||
label: formatMessage(labels.queryParameters),
|
||||
path: updateParams({ view: 'query' }),
|
||||
},
|
||||
{
|
||||
id: 'hostname',
|
||||
label: formatMessage(labels.hostname),
|
||||
path: updateParams({ view: 'hostname' }),
|
||||
},
|
||||
{
|
||||
id: 'tag',
|
||||
label: formatMessage(labels.tags),
|
||||
path: updateParams({ view: 'tag' }),
|
||||
},
|
||||
];
|
||||
|
||||
const renderChange = ({ x, y }) => {
|
||||
const prev = data.find(d => d.x === x)?.y;
|
||||
const value = y - prev;
|
||||
const change = Math.abs(((y - prev) / prev) * 100);
|
||||
|
||||
return (
|
||||
!isNaN(change) && (
|
||||
<Row alignItems="center" marginRight="3">
|
||||
<ChangeLabel value={value}>{formatNumber(change)}%</ChangeLabel>
|
||||
</Row>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const { startDate, endDate } = getCompareDate(
|
||||
dateCompare,
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
);
|
||||
|
||||
const params = {
|
||||
startAt: startDate.getTime(),
|
||||
endAt: endDate.getTime(),
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel>
|
||||
<Grid columns={{ xs: '1fr', lg: '200px 1fr 1fr' }} gap="6">
|
||||
<NavMenu>
|
||||
{items.map(({ id, label }) => {
|
||||
return (
|
||||
<NavMenuItem key={id}>
|
||||
<Text>{label}</Text>
|
||||
</NavMenuItem>
|
||||
);
|
||||
})}
|
||||
</NavMenu>
|
||||
<Column border="left" paddingLeft="6" gap="6">
|
||||
<Row alignItems="center" justifyContent="space-between">
|
||||
<Heading size="1">{formatMessage(labels.previous)}</Heading>
|
||||
<DateDisplay startDate={startDate} endDate={endDate} />
|
||||
</Row>
|
||||
<Component websiteId={websiteId} limit={20} showMore={false} params={params} />
|
||||
</Column>
|
||||
<Column border="left" paddingLeft="6" gap="6">
|
||||
<Row alignItems="center" justifyContent="space-between">
|
||||
<Heading size="1"> {formatMessage(labels.current)}</Heading>
|
||||
<DateDisplay startDate={dateRange.startDate} endDate={dateRange.endDate} />
|
||||
</Row>
|
||||
<Component
|
||||
websiteId={websiteId}
|
||||
limit={20}
|
||||
showMore={false}
|
||||
renderChange={renderChange}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,15 +10,15 @@ export function WebsiteControls({
|
|||
allowFilter = true,
|
||||
allowDateFilter = true,
|
||||
allowMonthFilter,
|
||||
allowCompare,
|
||||
allowDownload = false,
|
||||
allowCompare = false,
|
||||
}: {
|
||||
websiteId: string;
|
||||
allowFilter?: boolean;
|
||||
allowCompare?: boolean;
|
||||
allowDateFilter?: boolean;
|
||||
allowMonthFilter?: boolean;
|
||||
allowDownload?: boolean;
|
||||
allowCompare?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Column gap>
|
||||
|
|
|
|||
|
|
@ -1,43 +1,7 @@
|
|||
import { Grid, Column, NavMenu, NavMenuItem } from '@umami/react-zen';
|
||||
import { Grid, Column } from '@umami/react-zen';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { BrowsersTable } from '@/components/metrics/BrowsersTable';
|
||||
import { CitiesTable } from '@/components/metrics/CitiesTable';
|
||||
import { CountriesTable } from '@/components/metrics/CountriesTable';
|
||||
import { DevicesTable } from '@/components/metrics/DevicesTable';
|
||||
import { EventsTable } from '@/components/metrics/EventsTable';
|
||||
import { HostnamesTable } from '@/components/metrics/HostnamesTable';
|
||||
import { LanguagesTable } from '@/components/metrics/LanguagesTable';
|
||||
import { OSTable } from '@/components/metrics/OSTable';
|
||||
import { PagesTable } from '@/components/metrics/PagesTable';
|
||||
import { QueryParametersTable } from '@/components/metrics/QueryParametersTable';
|
||||
import { ReferrersTable } from '@/components/metrics/ReferrersTable';
|
||||
import { RegionsTable } from '@/components/metrics/RegionsTable';
|
||||
import { ScreenTable } from '@/components/metrics/ScreenTable';
|
||||
import { TagsTable } from '@/components/metrics/TagsTable';
|
||||
import { ChannelsTable } from '@/components/metrics/ChannelsTable';
|
||||
import Link from 'next/link';
|
||||
|
||||
const views = {
|
||||
path: PagesTable,
|
||||
entry: PagesTable,
|
||||
exit: PagesTable,
|
||||
title: PagesTable,
|
||||
referrer: ReferrersTable,
|
||||
grouped: ReferrersTable,
|
||||
hostname: HostnamesTable,
|
||||
browser: BrowsersTable,
|
||||
os: OSTable,
|
||||
device: DevicesTable,
|
||||
screen: ScreenTable,
|
||||
country: CountriesTable,
|
||||
region: RegionsTable,
|
||||
city: CitiesTable,
|
||||
language: LanguagesTable,
|
||||
event: EventsTable,
|
||||
query: QueryParametersTable,
|
||||
tag: TagsTable,
|
||||
channel: ChannelsTable,
|
||||
};
|
||||
import { MetricsExpandedTable } from '@/components/metrics/MetricsExpandedTable';
|
||||
import { SideMenu } from '@/components/common/SideMenu';
|
||||
|
||||
export function WebsiteExpandedView({
|
||||
websiteId,
|
||||
|
|
@ -54,106 +18,137 @@ export function WebsiteExpandedView({
|
|||
|
||||
const items = [
|
||||
{
|
||||
id: 'path',
|
||||
label: formatMessage(labels.pages),
|
||||
path: updateParams({ view: 'path' }),
|
||||
items: [
|
||||
{
|
||||
id: 'path',
|
||||
label: formatMessage(labels.path),
|
||||
path: updateParams({ view: 'path' }),
|
||||
},
|
||||
{
|
||||
id: 'entry',
|
||||
label: formatMessage(labels.entry),
|
||||
path: updateParams({ view: 'entry' }),
|
||||
},
|
||||
{
|
||||
id: 'exit',
|
||||
label: formatMessage(labels.exit),
|
||||
path: updateParams({ view: 'exit' }),
|
||||
},
|
||||
{
|
||||
id: 'title',
|
||||
label: formatMessage(labels.title),
|
||||
path: updateParams({ view: 'title' }),
|
||||
},
|
||||
{
|
||||
id: 'query',
|
||||
label: formatMessage(labels.query),
|
||||
path: updateParams({ view: 'query' }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'referrer',
|
||||
label: formatMessage(labels.referrers),
|
||||
path: updateParams({ view: 'referrer' }),
|
||||
label: formatMessage(labels.sources),
|
||||
items: [
|
||||
{
|
||||
id: 'referrer',
|
||||
label: formatMessage(labels.referrers),
|
||||
path: updateParams({ view: 'referrer' }),
|
||||
},
|
||||
{
|
||||
id: 'channel',
|
||||
label: formatMessage(labels.channels),
|
||||
path: updateParams({ view: 'channel' }),
|
||||
},
|
||||
{
|
||||
id: 'domain',
|
||||
label: formatMessage(labels.domain),
|
||||
path: updateParams({ view: 'domain' }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'channel',
|
||||
label: formatMessage(labels.channels),
|
||||
path: updateParams({ view: 'channel' }),
|
||||
label: formatMessage(labels.location),
|
||||
items: [
|
||||
{
|
||||
id: 'country',
|
||||
label: formatMessage(labels.countries),
|
||||
path: updateParams({ view: 'country' }),
|
||||
},
|
||||
{
|
||||
id: 'region',
|
||||
label: formatMessage(labels.regions),
|
||||
path: updateParams({ view: 'region' }),
|
||||
},
|
||||
{
|
||||
id: 'city',
|
||||
label: formatMessage(labels.cities),
|
||||
path: updateParams({ view: 'city' }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'browser',
|
||||
label: formatMessage(labels.browsers),
|
||||
path: updateParams({ view: 'browser' }),
|
||||
label: formatMessage(labels.environment),
|
||||
items: [
|
||||
{
|
||||
id: 'browser',
|
||||
label: formatMessage(labels.browsers),
|
||||
path: updateParams({ view: 'browser' }),
|
||||
},
|
||||
{
|
||||
id: 'os',
|
||||
label: formatMessage(labels.os),
|
||||
path: updateParams({ view: 'os' }),
|
||||
},
|
||||
{
|
||||
id: 'device',
|
||||
label: formatMessage(labels.devices),
|
||||
path: updateParams({ view: 'device' }),
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
label: formatMessage(labels.languages),
|
||||
path: updateParams({ view: 'language' }),
|
||||
},
|
||||
{
|
||||
id: 'screen',
|
||||
label: formatMessage(labels.screens),
|
||||
path: updateParams({ view: 'screen' }),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'os',
|
||||
label: formatMessage(labels.os),
|
||||
path: updateParams({ view: 'os' }),
|
||||
},
|
||||
{
|
||||
id: 'device',
|
||||
label: formatMessage(labels.devices),
|
||||
path: updateParams({ view: 'device' }),
|
||||
},
|
||||
{
|
||||
id: 'country',
|
||||
label: formatMessage(labels.countries),
|
||||
path: updateParams({ view: 'country' }),
|
||||
},
|
||||
{
|
||||
id: 'region',
|
||||
label: formatMessage(labels.regions),
|
||||
path: updateParams({ view: 'region' }),
|
||||
},
|
||||
{
|
||||
id: 'city',
|
||||
label: formatMessage(labels.cities),
|
||||
path: updateParams({ view: 'city' }),
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
label: formatMessage(labels.languages),
|
||||
path: updateParams({ view: 'language' }),
|
||||
},
|
||||
{
|
||||
id: 'screen',
|
||||
label: formatMessage(labels.screens),
|
||||
path: updateParams({ view: 'screen' }),
|
||||
},
|
||||
{
|
||||
id: 'event',
|
||||
label: formatMessage(labels.events),
|
||||
path: updateParams({ view: 'event' }),
|
||||
},
|
||||
{
|
||||
id: 'query',
|
||||
label: formatMessage(labels.queryParameters),
|
||||
path: updateParams({ view: 'query' }),
|
||||
},
|
||||
{
|
||||
id: 'hostname',
|
||||
label: formatMessage(labels.hostname),
|
||||
path: updateParams({ view: 'hostname' }),
|
||||
},
|
||||
{
|
||||
id: 'tag',
|
||||
label: formatMessage(labels.tags),
|
||||
path: updateParams({ view: 'tag' }),
|
||||
label: formatMessage(labels.other),
|
||||
items: [
|
||||
{
|
||||
id: 'event',
|
||||
label: formatMessage(labels.events),
|
||||
path: updateParams({ view: 'event' }),
|
||||
},
|
||||
{
|
||||
id: 'hostname',
|
||||
label: formatMessage(labels.hostname),
|
||||
path: updateParams({ view: 'hostname' }),
|
||||
},
|
||||
{
|
||||
id: 'tag',
|
||||
label: formatMessage(labels.tags),
|
||||
path: updateParams({ view: 'tag' }),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const DetailsComponent = views[view] || (() => null);
|
||||
|
||||
return (
|
||||
<Grid columns="auto 1fr" gap="6" height="100%" overflow="hidden">
|
||||
<Column gap="6" width="200px" border="right" paddingRight="3">
|
||||
<NavMenu position="sticky" top="0">
|
||||
{items.map(({ id, label, path }) => {
|
||||
return (
|
||||
<Link key={id} href={path}>
|
||||
<NavMenuItem isSelected={id === view}>{label}</NavMenuItem>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</NavMenu>
|
||||
<Column gap="6" border="right" paddingRight="3">
|
||||
<SideMenu items={items} selectedKey={view} />
|
||||
</Column>
|
||||
<Column overflow="hidden">
|
||||
<DetailsComponent
|
||||
<MetricsExpandedTable
|
||||
title={formatMessage(labels[view])}
|
||||
type={view}
|
||||
websiteId={websiteId}
|
||||
animate={false}
|
||||
virtualize={true}
|
||||
itemCount={25}
|
||||
allowFilter={true}
|
||||
allowSearch={true}
|
||||
isExpanded={true}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Column>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { Button, Icon, Text, Row, DialogTrigger, Dialog, Modal } from '@umami/react-zen';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { useWebsite } from '@/components/hooks/useWebsite';
|
||||
import { Share, Edit } from '@/components/icons';
|
||||
import { Favicon } from '@/components/common/Favicon';
|
||||
import { ActiveUsers } from '@/components/metrics/ActiveUsers';
|
||||
import { WebsiteShareForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
|
||||
import { LinkButton } from '@/components/common/LinkButton';
|
||||
|
||||
export function WebsiteHeader() {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
'use client';
|
||||
import { ReactNode } from 'react';
|
||||
import { Column, Grid } from '@umami/react-zen';
|
||||
import { WebsiteProvider } from './WebsiteProvider';
|
||||
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { WebsiteHeader } from './WebsiteHeader';
|
||||
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
|
||||
import { WebsiteNav } from './WebsiteNav';
|
||||
|
||||
export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
Network,
|
||||
ChartPie,
|
||||
UserPlus,
|
||||
Compare,
|
||||
} from '@/components/icons';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { SideMenu } from '@/components/common/SideMenu';
|
||||
|
|
@ -22,7 +23,8 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
|
|||
const { formatMessage, labels } = useMessages();
|
||||
const { pathname, renderUrl, teamId } = useNavigation();
|
||||
|
||||
const renderPath = (path: string) => renderUrl(`/websites/${websiteId}${path}`);
|
||||
const renderPath = (path: string) =>
|
||||
renderUrl(`/websites/${websiteId}${path}`, { event: undefined });
|
||||
|
||||
const items = [
|
||||
{
|
||||
|
|
@ -52,6 +54,12 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
|
|||
icon: <Clock />,
|
||||
path: renderPath('/realtime'),
|
||||
},
|
||||
{
|
||||
id: 'compare',
|
||||
label: formatMessage(labels.compare),
|
||||
icon: <Compare />,
|
||||
path: renderPath('/compare'),
|
||||
},
|
||||
{
|
||||
id: 'breakdown',
|
||||
label: formatMessage(labels.breakdown),
|
||||
|
|
@ -132,8 +140,8 @@ export function WebsiteNav({ websiteId }: { websiteId: string }) {
|
|||
];
|
||||
|
||||
const selectedKey =
|
||||
items.flatMap(e => e.items).find(({ path }) => path && pathname.endsWith(path))?.id ||
|
||||
'overview';
|
||||
items.flatMap(e => e.items).find(({ path }) => path && pathname.endsWith(path.split('?')[0]))
|
||||
?.id || 'overview';
|
||||
|
||||
return (
|
||||
<SideMenu items={items} selectedKey={selectedKey} allowMinimize={false}>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ import { Panel } from '@/components/common/Panel';
|
|||
import { WebsiteChart } from './WebsiteChart';
|
||||
import { WebsiteExpandedView } from './WebsiteExpandedView';
|
||||
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
|
||||
import { WebsiteTableView } from './WebsiteTableView';
|
||||
import { WebsitePanels } from './WebsitePanels';
|
||||
import { WebsiteControls } from './WebsiteControls';
|
||||
|
||||
export function WebsiteDetailsPage({ websiteId }: { websiteId: string }) {
|
||||
export function WebsitePage({ websiteId }: { websiteId: string }) {
|
||||
const {
|
||||
router,
|
||||
query: { view, compare },
|
||||
|
|
@ -27,12 +27,12 @@ export function WebsiteDetailsPage({ websiteId }: { websiteId: string }) {
|
|||
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} allowCompare={true} />
|
||||
<WebsiteControls websiteId={websiteId} />
|
||||
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
|
||||
<Panel>
|
||||
<Panel minHeight="520px">
|
||||
<WebsiteChart websiteId={websiteId} compareMode={compare} />
|
||||
</Panel>
|
||||
<WebsiteTableView websiteId={websiteId} />
|
||||
<WebsitePanels websiteId={websiteId} />
|
||||
<Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable>
|
||||
<Dialog style={{ maxWidth: 1320, width: '100vw', height: 'calc(100vh - 40px)' }}>
|
||||
{({ close }) => {
|
||||
114
src/app/(main)/websites/[websiteId]/WebsitePanels.tsx
Normal file
114
src/app/(main)/websites/[websiteId]/WebsitePanels.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { Grid, Tabs, Tab, TabList, TabPanel, Heading, Row } from '@umami/react-zen';
|
||||
import { GridRow } from '@/components/common/GridRow';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { WorldMap } from '@/components/metrics/WorldMap';
|
||||
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||
import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export function WebsitePanels({ websiteId }: { websiteId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const tableProps = {
|
||||
websiteId,
|
||||
limit: 10,
|
||||
allowDownload: false,
|
||||
showMore: true,
|
||||
metric: formatMessage(labels.visitors),
|
||||
};
|
||||
const rowProps = { minHeight: 570 };
|
||||
|
||||
return (
|
||||
<Grid gap="3">
|
||||
<GridRow layout="two" {...rowProps}>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(labels.pages)}</Heading>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="path">{formatMessage(labels.path)}</Tab>
|
||||
<Tab id="entry">{formatMessage(labels.entry)}</Tab>
|
||||
<Tab id="exit">{formatMessage(labels.exit)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="path">
|
||||
<MetricsTable type="path" title={formatMessage(labels.path)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="entry">
|
||||
<MetricsTable type="entry" title={formatMessage(labels.path)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="exit">
|
||||
<MetricsTable type="exit" title={formatMessage(labels.path)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(labels.sources)}</Heading>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
|
||||
<Tab id="channel">{formatMessage(labels.channels)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="referrer">
|
||||
<MetricsTable
|
||||
type="referrer"
|
||||
title={formatMessage(labels.referrer)}
|
||||
{...tableProps}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="channel">
|
||||
<MetricsTable type="channel" title={formatMessage(labels.channel)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
</GridRow>
|
||||
<GridRow layout="two-one" {...rowProps}>
|
||||
<Panel gridColumn="span 2" noPadding>
|
||||
<WorldMap websiteId={websiteId} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(labels.location)}</Heading>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="country">{formatMessage(labels.countries)}</Tab>
|
||||
<Tab id="region">{formatMessage(labels.regions)}</Tab>
|
||||
<Tab id="city">{formatMessage(labels.cities)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="country">
|
||||
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="region">
|
||||
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="city">
|
||||
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
</GridRow>
|
||||
<GridRow layout="two" {...rowProps}>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(labels.environment)}</Heading>
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab id="browser">{formatMessage(labels.browsers)}</Tab>
|
||||
<Tab id="os">{formatMessage(labels.os)}</Tab>
|
||||
<Tab id="device">{formatMessage(labels.devices)}</Tab>
|
||||
</TabList>
|
||||
<TabPanel id="browser">
|
||||
<MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="os">
|
||||
<MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
|
||||
</TabPanel>
|
||||
<TabPanel id="device">
|
||||
<MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</Panel>
|
||||
<Panel>
|
||||
<Heading size="2">{formatMessage(labels.traffic)}</Heading>
|
||||
<Row border="bottom" marginBottom="4" />
|
||||
<WeeklyTraffic websiteId={websiteId} />
|
||||
</Panel>
|
||||
</GridRow>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { Grid } from '@umami/react-zen';
|
||||
import { GridRow } from '@/components/common/GridRow';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { PagesTable } from '@/components/metrics/PagesTable';
|
||||
import { ReferrersTable } from '@/components/metrics/ReferrersTable';
|
||||
import { BrowsersTable } from '@/components/metrics/BrowsersTable';
|
||||
import { OSTable } from '@/components/metrics/OSTable';
|
||||
import { DevicesTable } from '@/components/metrics/DevicesTable';
|
||||
import { WorldMap } from '@/components/metrics/WorldMap';
|
||||
import { CountriesTable } from '@/components/metrics/CountriesTable';
|
||||
|
||||
export function WebsiteTableView({ websiteId }: { websiteId: string }) {
|
||||
const props = { websiteId, limit: 10, allowDownload: false };
|
||||
|
||||
return (
|
||||
<Grid gap="3">
|
||||
<GridRow layout="two">
|
||||
<Panel>
|
||||
<PagesTable {...props} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<ReferrersTable {...props} />
|
||||
</Panel>
|
||||
</GridRow>
|
||||
<GridRow layout="three">
|
||||
<Panel>
|
||||
<BrowsersTable {...props} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<OSTable {...props} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<DevicesTable {...props} />
|
||||
</Panel>
|
||||
</GridRow>
|
||||
<GridRow layout="two-one">
|
||||
<Panel gridColumn="span 2" noPadding>
|
||||
<WorldMap websiteId={websiteId} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<CountriesTable {...props} />
|
||||
</Panel>
|
||||
</GridRow>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { Tabs, TabList, Tab, Icon, Text, Row } from '@umami/react-zen';
|
||||
import { useWebsite } from '@/components/hooks/useWebsite';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { useMessages, useNavigation, useWebsite } from '@/components/hooks';
|
||||
import { Clock, Eye, Lightning, User, ChartPie } from '@/components/icons';
|
||||
|
||||
export function WebsiteTabs() {
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import { DateDistance } from '@/components/common/DateDistance';
|
|||
import { filtersObjectToArray } from '@/lib/params';
|
||||
import { CohortEditButton } from '@/app/(main)/websites/[websiteId]/cohorts/CohortEditButton';
|
||||
import { CohortDeleteButton } from '@/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function CohortsTable({ data = [] }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { websiteId } = useNavigation();
|
||||
const { websiteId, renderUrl } = useNavigation();
|
||||
|
||||
if (data.length === 0) {
|
||||
return <Empty />;
|
||||
|
|
@ -16,7 +17,11 @@ export function CohortsTable({ data = [] }) {
|
|||
|
||||
return (
|
||||
<DataTable data={data}>
|
||||
<DataColumn id="name" label={formatMessage(labels.name)} />
|
||||
<DataColumn id="name" label={formatMessage(labels.name)}>
|
||||
{(row: any) => (
|
||||
<Link href={renderUrl(`/websites/${websiteId}?cohort=${row.id}`)}>{row.name}</Link>
|
||||
)}
|
||||
</DataColumn>
|
||||
<DataColumn id="created" label={formatMessage(labels.created)}>
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
|
|
|
|||
20
src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx
Normal file
20
src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
'use client';
|
||||
import { Column } from '@umami/react-zen';
|
||||
import { CompareTables } from './CompareTables';
|
||||
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
|
||||
import { WebsiteMetricsBar } from '@/app/(main)/websites/[websiteId]/WebsiteMetricsBar';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
|
||||
|
||||
export function ComparePage({ websiteId }: { websiteId: string }) {
|
||||
return (
|
||||
<Column gap>
|
||||
<WebsiteControls websiteId={websiteId} allowCompare={true} />
|
||||
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
|
||||
<Panel minHeight="520px">
|
||||
<WebsiteChart websiteId={websiteId} compareMode={true} />
|
||||
</Panel>
|
||||
<CompareTables websiteId={websiteId} />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
169
src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx
Normal file
169
src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { useState } from 'react';
|
||||
import { Grid, Heading, Column, Row, Select, ListItem } from '@umami/react-zen';
|
||||
import { useDateRange, useMessages, useNavigation } from '@/components/hooks';
|
||||
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
import { DateDisplay } from '@/components/common/DateDisplay';
|
||||
import { ChangeLabel } from '@/components/metrics/ChangeLabel';
|
||||
import { getCompareDate } from '@/lib/date';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
|
||||
export function CompareTables({ websiteId }: { websiteId: string }) {
|
||||
const [data, setData] = useState([]);
|
||||
const { dateRange, dateCompare } = useDateRange(websiteId);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const {
|
||||
router,
|
||||
updateParams,
|
||||
query: { view = 'path' },
|
||||
} = useNavigation();
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: 'path',
|
||||
label: formatMessage(labels.path),
|
||||
path: updateParams({ view: 'path' }),
|
||||
},
|
||||
{
|
||||
id: 'referrer',
|
||||
label: formatMessage(labels.referrers),
|
||||
path: updateParams({ view: 'referrer' }),
|
||||
},
|
||||
{
|
||||
id: 'browser',
|
||||
label: formatMessage(labels.browsers),
|
||||
path: updateParams({ view: 'browser' }),
|
||||
},
|
||||
{
|
||||
id: 'os',
|
||||
label: formatMessage(labels.os),
|
||||
path: updateParams({ view: 'os' }),
|
||||
},
|
||||
{
|
||||
id: 'device',
|
||||
label: formatMessage(labels.devices),
|
||||
path: updateParams({ view: 'device' }),
|
||||
},
|
||||
{
|
||||
id: 'country',
|
||||
label: formatMessage(labels.countries),
|
||||
path: updateParams({ view: 'country' }),
|
||||
},
|
||||
{
|
||||
id: 'region',
|
||||
label: formatMessage(labels.regions),
|
||||
path: updateParams({ view: 'region' }),
|
||||
},
|
||||
{
|
||||
id: 'city',
|
||||
label: formatMessage(labels.cities),
|
||||
path: updateParams({ view: 'city' }),
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
label: formatMessage(labels.languages),
|
||||
path: updateParams({ view: 'language' }),
|
||||
},
|
||||
{
|
||||
id: 'screen',
|
||||
label: formatMessage(labels.screens),
|
||||
path: updateParams({ view: 'screen' }),
|
||||
},
|
||||
{
|
||||
id: 'event',
|
||||
label: formatMessage(labels.events),
|
||||
path: updateParams({ view: 'event' }),
|
||||
},
|
||||
{
|
||||
id: 'hostname',
|
||||
label: formatMessage(labels.hostname),
|
||||
path: updateParams({ view: 'hostname' }),
|
||||
},
|
||||
{
|
||||
id: 'tag',
|
||||
label: formatMessage(labels.tags),
|
||||
path: updateParams({ view: 'tag' }),
|
||||
},
|
||||
];
|
||||
|
||||
const renderChange = props => {
|
||||
const { label: x, count: y } = props;
|
||||
const prev = data.find(d => d.x === x)?.y;
|
||||
const value = y - prev;
|
||||
const change = Math.abs(((y - prev) / prev) * 100);
|
||||
|
||||
return (
|
||||
!isNaN(change) && (
|
||||
<Row alignItems="center" marginRight="3">
|
||||
<ChangeLabel value={value}>{formatNumber(change)}%</ChangeLabel>
|
||||
</Row>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleChange = id => {
|
||||
router.push(updateParams({ view: id }));
|
||||
};
|
||||
|
||||
const { startDate, endDate } = getCompareDate(
|
||||
dateCompare,
|
||||
dateRange.startDate,
|
||||
dateRange.endDate,
|
||||
);
|
||||
|
||||
const params = {
|
||||
startAt: startDate.getTime(),
|
||||
endAt: endDate.getTime(),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row width="300px">
|
||||
<Select
|
||||
items={items}
|
||||
label={formatMessage(labels.compare)}
|
||||
value={view}
|
||||
defaultValue={view}
|
||||
onChange={handleChange}
|
||||
>
|
||||
{items.map(({ id, label }) => (
|
||||
<ListItem key={id} id={id}>
|
||||
{label}
|
||||
</ListItem>
|
||||
))}
|
||||
</Select>
|
||||
</Row>
|
||||
<Panel>
|
||||
<Grid columns={{ xs: '1fr', lg: '1fr 1fr' }} gap="6">
|
||||
<Column gap="6">
|
||||
<Row alignItems="center" justifyContent="space-between">
|
||||
<Heading size="2">{formatMessage(labels.previous)}</Heading>
|
||||
<DateDisplay startDate={startDate} endDate={endDate} />
|
||||
</Row>
|
||||
<MetricsTable
|
||||
websiteId={websiteId}
|
||||
type={view}
|
||||
limit={20}
|
||||
showMore={false}
|
||||
params={params}
|
||||
onDataLoad={setData}
|
||||
/>
|
||||
</Column>
|
||||
<Column border="left" paddingLeft="6" gap="6">
|
||||
<Row alignItems="center" justifyContent="space-between">
|
||||
<Heading size="2"> {formatMessage(labels.current)}</Heading>
|
||||
<DateDisplay startDate={dateRange.startDate} endDate={dateRange.endDate} />
|
||||
</Row>
|
||||
<MetricsTable
|
||||
websiteId={websiteId}
|
||||
type={view}
|
||||
limit={20}
|
||||
showMore={false}
|
||||
renderChange={renderChange}
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
src/app/(main)/websites/[websiteId]/compare/page.tsx
Normal file
12
src/app/(main)/websites/[websiteId]/compare/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Metadata } from 'next';
|
||||
import { ComparePage } from './ComparePage';
|
||||
|
||||
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||
const { websiteId } = await params;
|
||||
|
||||
return <ComparePage websiteId={websiteId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Compare',
|
||||
};
|
||||
|
|
@ -100,9 +100,9 @@ const EventValues = ({ websiteId, eventName, propertyName }) => {
|
|||
const tableData = useMemo(() => {
|
||||
if (!propertyName || !values || propertySum === 0) return [];
|
||||
return values.map(({ value, total }) => ({
|
||||
x: value,
|
||||
y: total,
|
||||
z: 100 * (total / propertySum),
|
||||
label: value,
|
||||
count: total,
|
||||
percent: 100 * (total / propertySum),
|
||||
}));
|
||||
}, [propertyName, values, propertySum]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
import { TabList, Tab, Tabs, TabPanel, Column } from '@umami/react-zen';
|
||||
import { EventsTable } from '@/components/metrics/EventsTable';
|
||||
import { MetricsTable } from '@/components/metrics/MetricsTable';
|
||||
import { useState, Key } from 'react';
|
||||
import { EventsDataTable } from './EventsDataTable';
|
||||
import { Panel } from '@/components/common/Panel';
|
||||
|
|
@ -13,14 +13,9 @@ import { getItem, setItem } from '@/lib/storage';
|
|||
const KEY_NAME = 'umami.events.tab';
|
||||
|
||||
export function EventsPage({ websiteId }) {
|
||||
const [label, setLabel] = useState(null);
|
||||
const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart');
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const handleLabelClick = (value: string) => {
|
||||
setLabel(value !== label ? value : '');
|
||||
};
|
||||
|
||||
const handleSelect = (value: Key) => {
|
||||
setItem(KEY_NAME, value);
|
||||
setTab(value);
|
||||
|
|
@ -42,14 +37,13 @@ export function EventsPage({ websiteId }) {
|
|||
<TabPanel id="chart">
|
||||
<Column gap="6">
|
||||
<Column border="bottom" paddingBottom="6">
|
||||
<EventsChart websiteId={websiteId} focusLabel={label} />
|
||||
<EventsChart websiteId={websiteId} />
|
||||
</Column>
|
||||
<EventsTable
|
||||
<MetricsTable
|
||||
websiteId={websiteId}
|
||||
type="event"
|
||||
title={formatMessage(labels.events)}
|
||||
metric={formatMessage(labels.actions)}
|
||||
onLabelClick={handleLabelClick}
|
||||
metric={formatMessage(labels.count)}
|
||||
/>
|
||||
</Column>
|
||||
</TabPanel>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { WebsiteDetailsPage } from './WebsiteDetailsPage';
|
||||
import { WebsitePage } from './WebsitePage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default async function WebsitePage({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
|
||||
const { websiteId } = await params;
|
||||
|
||||
return <WebsiteDetailsPage websiteId={websiteId} />;
|
||||
return <WebsitePage websiteId={websiteId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
import { useFormat } from '@/components//hooks/useFormat';
|
||||
import { Empty } from '@/components/common/Empty';
|
||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
||||
import { useCountryNames, useLocale, useMessages, useTimezone } from '@/components/hooks';
|
||||
import {
|
||||
useCountryNames,
|
||||
useLocale,
|
||||
useMessages,
|
||||
useTimezone,
|
||||
useWebsite,
|
||||
} from '@/components/hooks';
|
||||
import { Eye, Visitor, Bolt } from '@/components/icons';
|
||||
import { BROWSERS, OS_NAMES } from '@/lib/constants';
|
||||
import { stringToColor } from '@/lib/format';
|
||||
import { RealtimeData } from '@/lib/types';
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Icon, SearchField, StatusLight, Text } from '@umami/react-zen';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import { WebsiteContext } from '../WebsiteProvider';
|
||||
import styles from './RealtimeLog.module.css';
|
||||
|
||||
const TYPE_ALL = 'all';
|
||||
|
|
@ -23,8 +27,8 @@ const icons = {
|
|||
[TYPE_EVENT]: <Bolt />,
|
||||
};
|
||||
|
||||
export function RealtimeLog({ data }: { data: RealtimeData }) {
|
||||
const website = useContext(WebsiteContext);
|
||||
export function RealtimeLog({ data }: { data: any }) {
|
||||
const website = useWebsite();
|
||||
const [search, setSearch] = useState('');
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { formatValue } = useFormat();
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
import { useContext, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Row } from '@umami/react-zen';
|
||||
import thenby from 'thenby';
|
||||
import { percentFilter } from '@/lib/filters';
|
||||
import { ListTable } from '@/components/metrics/ListTable';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { RealtimeData } from '@/lib/types';
|
||||
import { WebsiteContext } from '../WebsiteProvider';
|
||||
import { useMessages, useWebsite } from '@/components/hooks';
|
||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
||||
|
||||
const FILTER_REFERRERS = 'filter-referrers';
|
||||
const FILTER_PAGES = 'filter-pages';
|
||||
|
||||
export function RealtimeUrls({ data }: { data: RealtimeData }) {
|
||||
const website = useContext(WebsiteContext);
|
||||
export function RealtimeUrls({ data }: { data: any }) {
|
||||
const website = useWebsite();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { referrers, urls } = data || {};
|
||||
const [filter, setFilter] = useState(FILTER_REFERRERS);
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import {
|
|||
import { subMonths, endOfDay } from 'date-fns';
|
||||
import { FieldFilters } from '@/components/input/FieldFilters';
|
||||
import { useState } from 'react';
|
||||
import { useApi, useMessages, useModified, useWebsiteSegmentQuery } from '@/components/hooks';
|
||||
import { useMessages, useUpdateQuery, useWebsiteSegmentQuery } from '@/components/hooks';
|
||||
import { filtersArrayToObject } from '@/lib/params';
|
||||
import { messages } from '@/components/messages';
|
||||
|
||||
export function SegmentEditForm({
|
||||
segmentId,
|
||||
|
|
@ -32,24 +33,23 @@ export function SegmentEditForm({
|
|||
const { data } = useWebsiteSegmentQuery(websiteId, segmentId);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [currentFilters, setCurrentFilters] = useState(filters);
|
||||
const { touch } = useModified();
|
||||
const startDate = subMonths(endOfDay(new Date()), 6);
|
||||
const endDate = endOfDay(new Date());
|
||||
|
||||
const { post, useMutation } = useApi();
|
||||
const { mutate, error, isPending } = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
post(`/websites/${websiteId}/segments${segmentId ? `/${segmentId}` : ''}`, {
|
||||
...data,
|
||||
type: 'segment',
|
||||
}),
|
||||
});
|
||||
const { mutate, error, isPending, touch, toast } = useUpdateQuery(
|
||||
`/websites/${websiteId}/segments${segmentId ? `/${segmentId}` : ''}`,
|
||||
{
|
||||
...data,
|
||||
type: 'segment',
|
||||
},
|
||||
);
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
mutate(
|
||||
{ ...data, parameters: filtersArrayToObject(currentFilters) },
|
||||
{
|
||||
onSuccess: async () => {
|
||||
toast(formatMessage(messages.saved));
|
||||
touch('segments');
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import { DateDistance } from '@/components/common/DateDistance';
|
|||
import { filtersObjectToArray } from '@/lib/params';
|
||||
import { SegmentEditButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditButton';
|
||||
import { SegmentDeleteButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function SegmentsTable({ data = [] }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { websiteId } = useNavigation();
|
||||
const { websiteId, renderUrl } = useNavigation();
|
||||
|
||||
if (data.length === 0) {
|
||||
return <Empty />;
|
||||
|
|
@ -16,7 +17,11 @@ export function SegmentsTable({ data = [] }) {
|
|||
|
||||
return (
|
||||
<DataTable data={data}>
|
||||
<DataColumn id="name" label={formatMessage(labels.name)} />
|
||||
<DataColumn id="name" label={formatMessage(labels.name)}>
|
||||
{(row: any) => (
|
||||
<Link href={renderUrl(`/websites/${websiteId}?segment=${row.id}`)}>{row.name}</Link>
|
||||
)}
|
||||
</DataColumn>
|
||||
<DataColumn id="created" label={formatMessage(labels.created)}>
|
||||
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
|
||||
</DataColumn>
|
||||
|
|
|
|||
|
|
@ -71,9 +71,9 @@ const SessionValues = ({ websiteId, propertyName }) => {
|
|||
const tableData = useMemo(() => {
|
||||
if (!propertyName || !data || propertySum === 0) return [];
|
||||
return data.map(({ value, total }) => ({
|
||||
x: value,
|
||||
y: total,
|
||||
z: 100 * (total / propertySum),
|
||||
label: value,
|
||||
count: total,
|
||||
percent: 100 * (total / propertySum),
|
||||
}));
|
||||
}, [propertyName, data, propertySum]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useContext } from 'react';
|
||||
import {
|
||||
FormSubmitButton,
|
||||
Form,
|
||||
|
|
@ -7,12 +6,11 @@ import {
|
|||
TextField,
|
||||
useToast,
|
||||
} from '@umami/react-zen';
|
||||
import { useApi, useMessages, useModified } from '@/components/hooks';
|
||||
import { useApi, useMessages, useModified, useWebsite } from '@/components/hooks';
|
||||
import { DOMAIN_REGEX } from '@/lib/constants';
|
||||
import { WebsiteContext } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
||||
|
||||
export function WebsiteEditForm({ websiteId, onSave }: { websiteId: string; onSave?: () => void }) {
|
||||
const website = useContext(WebsiteContext);
|
||||
const website = useWebsite();
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { post, useMutation } = useApi();
|
||||
const { toast } = useToast();
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import { useContext } from 'react';
|
||||
import { Tabs, TabList, Tab, TabPanel } from '@umami/react-zen';
|
||||
import { WebsiteContext } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { useMessages, useWebsite } from '@/components/hooks';
|
||||
import { WebsiteShareForm } from './WebsiteShareForm';
|
||||
import { WebsiteTrackingCode } from './WebsiteTrackingCode';
|
||||
import { WebsiteData } from './WebsiteData';
|
||||
import { WebsiteEditForm } from './WebsiteEditForm';
|
||||
|
||||
export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) {
|
||||
const website = useContext(WebsiteContext);
|
||||
const website = useWebsite();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { useContext } from 'react';
|
||||
import { WebsiteContext } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
import { Globe } from '@/components/icons';
|
||||
import { useWebsite } from '@/components/hooks';
|
||||
|
||||
export function WebsiteSettingsHeader() {
|
||||
const website = useContext(WebsiteContext);
|
||||
const website = useWebsite();
|
||||
|
||||
return <PageHeader title={website?.name} icon={<Globe />} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Key, useContext, useState } from 'react';
|
||||
import { Key, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
|
|
@ -10,8 +10,13 @@ import {
|
|||
ListItem,
|
||||
Text,
|
||||
} from '@umami/react-zen';
|
||||
import { useApi, useLoginQuery, useMessages, useUserTeamsQuery } from '@/components/hooks';
|
||||
import { WebsiteContext } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
||||
import {
|
||||
useLoginQuery,
|
||||
useMessages,
|
||||
useUpdateQuery,
|
||||
useUserTeamsQuery,
|
||||
useWebsite,
|
||||
} from '@/components/hooks';
|
||||
import { ROLES } from '@/lib/constants';
|
||||
|
||||
export function WebsiteTransferForm({
|
||||
|
|
@ -24,13 +29,10 @@ export function WebsiteTransferForm({
|
|||
onClose?: () => void;
|
||||
}) {
|
||||
const { user } = useLoginQuery();
|
||||
const website = useContext(WebsiteContext);
|
||||
const website = useWebsite();
|
||||
const [teamId, setTeamId] = useState<string>(null);
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { post, useMutation } = useApi();
|
||||
const { mutate, error } = useMutation({
|
||||
mutationFn: (data: any) => post(`/websites/${websiteId}/transfer`, data),
|
||||
});
|
||||
const { mutate, error, isPending } = useUpdateQuery(`/websites/${websiteId}/transfer`);
|
||||
const { data: teams, isLoading } = useUserTeamsQuery(user.id);
|
||||
const isTeamWebsite = !!website?.teamId;
|
||||
|
||||
|
|
@ -87,7 +89,11 @@ export function WebsiteTransferForm({
|
|||
</FormField>
|
||||
<FormButtons>
|
||||
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
|
||||
<FormSubmitButton variant="primary" isDisabled={!isTeamWebsite && !teamId}>
|
||||
<FormSubmitButton
|
||||
variant="primary"
|
||||
isPending={isPending}
|
||||
isDisabled={!isTeamWebsite && !teamId}
|
||||
>
|
||||
{formatMessage(labels.transfer)}
|
||||
</FormSubmitButton>
|
||||
</FormButtons>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { getQueryFilters, parseRequest } from '@/lib/request';
|
|||
import { unauthorized, json } from '@/lib/response';
|
||||
import { canViewWebsite } from '@/validations';
|
||||
import { pagingParams, timezoneParam } from '@/lib/schema';
|
||||
import { getWebsiteSessionsWeekly } from '@/queries';
|
||||
import { getWeeklyTraffic } from '@/queries';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
|
|
@ -30,7 +30,7 @@ export async function GET(
|
|||
|
||||
const filters = await getQueryFilters(query, websiteId);
|
||||
|
||||
const data = await getWebsiteSessionsWeekly(websiteId, filters);
|
||||
const data = await getWeeklyTraffic(websiteId, filters);
|
||||
|
||||
return json(data);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
import { WebsiteProvider } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
||||
import { WebsiteDetailsPage } from '@/app/(main)/websites/[websiteId]/WebsiteDetailsPage';
|
||||
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
|
||||
import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage';
|
||||
import { useShareTokenQuery } from '@/components/hooks';
|
||||
import { PageBody } from '@/components/common/PageBody';
|
||||
import { Header } from './Header';
|
||||
|
|
@ -17,7 +17,7 @@ export function SharePage({ shareId }) {
|
|||
<PageBody>
|
||||
<Header />
|
||||
<WebsiteProvider websiteId={shareToken.websiteId}>
|
||||
<WebsiteDetailsPage websiteId={shareToken.websiteId} />
|
||||
<WebsitePage websiteId={shareToken.websiteId} />
|
||||
</WebsiteProvider>
|
||||
<Footer />
|
||||
</PageBody>
|
||||
|
|
|
|||
|
|
@ -1,54 +1,49 @@
|
|||
import { ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { HTMLAttributes, ReactNode, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Icon } from '@umami/react-zen';
|
||||
import { Icon, Row, Text } from '@umami/react-zen';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { ExternalLink } from '@/components/icons';
|
||||
import styles from './FilterLink.module.css';
|
||||
|
||||
export interface FilterLinkProps {
|
||||
id: string;
|
||||
export interface FilterLinkProps extends HTMLAttributes<HTMLDivElement> {
|
||||
type: string;
|
||||
value: string;
|
||||
label?: string;
|
||||
icon?: ReactNode;
|
||||
externalUrl?: string;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function FilterLink({
|
||||
id,
|
||||
value,
|
||||
label,
|
||||
externalUrl,
|
||||
children,
|
||||
className,
|
||||
}: FilterLinkProps) {
|
||||
export function FilterLink({ type, value, label, externalUrl, icon }: FilterLinkProps) {
|
||||
const [showLink, setShowLink] = useState(false);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { updateParams, query } = useNavigation();
|
||||
const active = query[id] !== undefined;
|
||||
const selected = query[id] === value;
|
||||
const active = query[type] !== undefined;
|
||||
const selected = query[type] === value;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.row, className, {
|
||||
[styles.inactive]: active && !selected,
|
||||
[styles.active]: active && selected,
|
||||
})}
|
||||
<Row
|
||||
alignItems="center"
|
||||
gap
|
||||
fontWeight={active && selected ? 'bold' : undefined}
|
||||
color={active && !selected ? 'muted' : undefined}
|
||||
onMouseOver={() => setShowLink(true)}
|
||||
onMouseOut={() => setShowLink(false)}
|
||||
>
|
||||
{children}
|
||||
{icon}
|
||||
{!value && `(${label || formatMessage(labels.unknown)})`}
|
||||
{value && (
|
||||
<Link href={updateParams({ [id]: `eq.${value}` })} className={styles.label} replace>
|
||||
{label || value}
|
||||
</Link>
|
||||
<Text title={label || value} truncate>
|
||||
<Link href={updateParams({ [type]: `eq.${value}` })} replace>
|
||||
{label || value}
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
{externalUrl && (
|
||||
<a className={styles.link} href={externalUrl} target="_blank" rel="noreferrer noopener">
|
||||
<Icon className={styles.icon}>
|
||||
{externalUrl && showLink && (
|
||||
<a href={externalUrl} target="_blank" rel="noreferrer noopener">
|
||||
<Icon color="muted">
|
||||
<ExternalLink />
|
||||
</Icon>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export function LoadingPanel({
|
|||
return (
|
||||
<>
|
||||
{/* Show loading spinner only if no data exists */}
|
||||
{(isLoading || isFetching) && !data && (
|
||||
{(isLoading || isFetching) && (
|
||||
<Column position="relative" height="100%" {...props}>
|
||||
<Loading icon={loadingIcon} position="page" />
|
||||
</Column>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { Heading, Icon, Row, RowProps, Text } from '@umami/react-zen';
|
||||
import { Heading, Icon, Row, RowProps, Text, Column } from '@umami/react-zen';
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
|
|
@ -26,11 +26,17 @@ export function PageHeader({
|
|||
width="100%"
|
||||
{...props}
|
||||
>
|
||||
<Row alignItems="center" gap="3">
|
||||
{icon && <Icon size="md">{icon}</Icon>}
|
||||
{title && <Heading size="4">{title}</Heading>}
|
||||
<Column>
|
||||
<Row alignItems="center" gap="3">
|
||||
{icon && (
|
||||
<Icon size="md" color="muted">
|
||||
{icon}
|
||||
</Icon>
|
||||
)}
|
||||
{title && <Heading size="4">{title}</Heading>}
|
||||
</Row>
|
||||
{description && <Text color="muted">{description}</Text>}
|
||||
</Row>
|
||||
</Column>
|
||||
<Row justifyContent="flex-end">{children}</Row>
|
||||
</Row>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { ReactNode } from 'react';
|
||||
import {
|
||||
Text,
|
||||
Heading,
|
||||
|
|
@ -7,15 +8,16 @@ import {
|
|||
Row,
|
||||
Column,
|
||||
NavMenuGroup,
|
||||
NavMenuProps,
|
||||
} from '@umami/react-zen';
|
||||
import Link from 'next/link';
|
||||
|
||||
export interface SideMenuProps {
|
||||
export interface SideMenuProps extends NavMenuProps {
|
||||
items: { label: string; items: { id: string; label: string; icon?: any; path: string }[] }[];
|
||||
title?: string;
|
||||
selectedKey?: string;
|
||||
allowMinimize?: boolean;
|
||||
children?: React.ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function SideMenu({
|
||||
|
|
@ -24,6 +26,7 @@ export function SideMenu({
|
|||
selectedKey,
|
||||
allowMinimize,
|
||||
children,
|
||||
...props
|
||||
}: SideMenuProps) {
|
||||
return (
|
||||
<Column
|
||||
|
|
@ -42,12 +45,12 @@ export function SideMenu({
|
|||
<Heading size="1">{title}</Heading>
|
||||
</Row>
|
||||
)}
|
||||
<NavMenu muteItems={false} gap="6">
|
||||
{items?.map(({ label, items }) => {
|
||||
<NavMenu muteItems={false} gap="6" {...props}>
|
||||
{items?.map(({ label, items }, index) => {
|
||||
return (
|
||||
<NavMenuGroup
|
||||
title={label}
|
||||
key={label}
|
||||
key={`${label}${index}`}
|
||||
gap="1"
|
||||
allowMinimize={allowMinimize}
|
||||
marginBottom="3"
|
||||
|
|
|
|||
6
src/components/hooks/context/useLink.ts
Normal file
6
src/components/hooks/context/useLink.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { LinkContext } from '@/app/(main)/links/LinkProvider';
|
||||
import { useContext } from 'react';
|
||||
|
||||
export function useLink() {
|
||||
return useContext(LinkContext);
|
||||
}
|
||||
6
src/components/hooks/context/usePixel.ts
Normal file
6
src/components/hooks/context/usePixel.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { PixelContext } from '@/app/(main)/pixels/PixelProvider';
|
||||
import { useContext } from 'react';
|
||||
|
||||
export function usePixel() {
|
||||
return useContext(PixelContext);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { TeamContext } from '@/app/(main)/teams/[teamId]/TeamProvider';
|
||||
import { TeamContext } from '@/app/(main)/teams/TeamProvider';
|
||||
import { useContext } from 'react';
|
||||
|
||||
export function useTeam() {
|
||||
6
src/components/hooks/context/useUser.ts
Normal file
6
src/components/hooks/context/useUser.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { UserContext } from '@/app/(main)/admin/users/[userId]/UserProvider';
|
||||
import { useContext } from 'react';
|
||||
|
||||
export function useUser() {
|
||||
return useContext(UserContext);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { WebsiteContext } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
||||
import { WebsiteContext } from '@/app/(main)/websites/WebsiteProvider';
|
||||
import { useContext } from 'react';
|
||||
|
||||
export function useWebsite() {
|
||||
|
|
@ -1,5 +1,12 @@
|
|||
'use client';
|
||||
|
||||
// Context hooks
|
||||
export * from './context/useLink';
|
||||
export * from './context/usePixel';
|
||||
export * from './context/useTeam';
|
||||
export * from './context/useUser';
|
||||
export * from './context/useWebsite';
|
||||
|
||||
// Query hooks
|
||||
export * from './queries/useActiveUsersQuery';
|
||||
export * from './queries/useDeleteQuery';
|
||||
|
|
@ -43,7 +50,7 @@ export * from './queries/useWebsiteSegmentsQuery';
|
|||
export * from './queries/useWebsiteSessionQuery';
|
||||
export * from './queries/useWebsiteSessionStatsQuery';
|
||||
export * from './queries/useWebsiteSessionsQuery';
|
||||
export * from './queries/useWebsiteSessionsWeeklyQuery';
|
||||
export * from './queries/useWeeklyTrafficQuery';
|
||||
export * from './queries/useWebsiteStatsQuery';
|
||||
export * from './queries/useWebsiteValuesQuery';
|
||||
export * from './queries/useWebsitesQuery';
|
||||
|
|
@ -70,7 +77,6 @@ export * from './useNavigation';
|
|||
export * from './usePagedQuery';
|
||||
export * from './usePageParameters';
|
||||
export * from './useRegionNames';
|
||||
export * from './useSlug';
|
||||
export * from './useSticky';
|
||||
export * from './useTeam';
|
||||
export * from './useTimezone';
|
||||
export * from './useWebsite';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useApi, useModified } from '@/components/hooks';
|
||||
import { useApi } from '../useApi';
|
||||
import { useModified } from '../useModified';
|
||||
import { useToast } from '@umami/react-zen';
|
||||
|
||||
export function useUpdateQuery(path: string, params?: Record<string, any>) {
|
||||
const { post, useMutation } = useApi();
|
||||
|
|
@ -6,6 +8,7 @@ export function useUpdateQuery(path: string, params?: Record<string, any>) {
|
|||
mutationFn: (data: Record<string, any>) => post(path, { ...data, ...params }),
|
||||
});
|
||||
const { touch } = useModified();
|
||||
const { toast } = useToast();
|
||||
|
||||
return { mutate, isPending, error, touch };
|
||||
return { mutate, isPending, error, touch, toast };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ import { useModified } from '../useModified';
|
|||
import { useDateParameters } from '../useDateParameters';
|
||||
import { useFilterParameters } from '@/components/hooks/useFilterParameters';
|
||||
|
||||
export function useWebsiteSessionsWeeklyQuery(
|
||||
websiteId: string,
|
||||
params?: Record<string, string | number>,
|
||||
) {
|
||||
export function useWeeklyTrafficQuery(websiteId: string, params?: Record<string, string | number>) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { modified } = useModified(`sessions`);
|
||||
const date = useDateParameters(websiteId);
|
||||
|
|
@ -5,10 +5,8 @@ export function useFields() {
|
|||
|
||||
const fields = [
|
||||
{ name: 'path', type: 'string', label: formatMessage(labels.path) },
|
||||
// { name: 'cohort', type: 'string', label: formatMessage(labels.cohort) },
|
||||
{ name: 'title', type: 'string', label: formatMessage(labels.pageTitle) },
|
||||
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
|
||||
//{ name: 'query', type: 'string', label: formatMessage(labels.query) },
|
||||
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) },
|
||||
{ name: 'os', type: 'string', label: formatMessage(labels.os) },
|
||||
{ name: 'device', type: 'string', label: formatMessage(labels.device) },
|
||||
|
|
@ -17,6 +15,7 @@ export function useFields() {
|
|||
{ name: 'city', type: 'string', label: formatMessage(labels.city) },
|
||||
{ name: 'hostname', type: 'string', label: formatMessage(labels.hostname) },
|
||||
{ name: 'tag', type: 'string', label: formatMessage(labels.tag) },
|
||||
{ name: 'event', type: 'string', label: formatMessage(labels.event) },
|
||||
];
|
||||
|
||||
return { fields };
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ export function useRegionNames(locale: string) {
|
|||
return regions[regionCode];
|
||||
}
|
||||
|
||||
const region = regionCode.includes('-') ? regionCode : `${countryCode}-${regionCode}`;
|
||||
if (!regionCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const region = regionCode?.includes('-') ? regionCode : `${countryCode}-${regionCode}`;
|
||||
|
||||
return regions[region] ? `${regions[region]}, ${countryNames[countryCode]}` : region;
|
||||
};
|
||||
|
||||
|
|
|
|||
14
src/components/hooks/useSlug.ts
Normal file
14
src/components/hooks/useSlug.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { useConfig } from '@/components/hooks/useConfig';
|
||||
import { LINKS_URL, PIXELS_URL } from '@/lib/constants';
|
||||
|
||||
export function useSlug(type: 'link' | 'pixel') {
|
||||
const { linksUrl, pixelsUrl } = useConfig();
|
||||
|
||||
const hostUrl = type === 'link' ? linksUrl || LINKS_URL : pixelsUrl || PIXELS_URL;
|
||||
|
||||
const getSlugUrl = (slug: string) => {
|
||||
return `${hostUrl}/${slug}`;
|
||||
};
|
||||
|
||||
return { getSlugUrl, hostUrl };
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ export {
|
|||
FileJson,
|
||||
FileText,
|
||||
Globe,
|
||||
Grid2X2,
|
||||
Grid2X2 as Pixel,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Link,
|
||||
|
|
|
|||
|
|
@ -27,10 +27,11 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
|
|||
router,
|
||||
updateParams,
|
||||
replaceParams,
|
||||
query: { segment },
|
||||
query: { segment, cohort },
|
||||
} = useNavigation();
|
||||
const { filters, operatorLabels } = useFilters();
|
||||
const { data, isLoading } = useWebsiteSegmentQuery(websiteId, segment);
|
||||
const canSave = filters.length > 0 && !segment && !cohort;
|
||||
|
||||
const handleCloseFilter = (param: string) => {
|
||||
router.push(updateParams({ [param]: undefined }));
|
||||
|
|
@ -78,16 +79,18 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
|
|||
</Row>
|
||||
<Row alignItems="center">
|
||||
<DialogTrigger>
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button variant="zero">
|
||||
<Icon>
|
||||
<Bookmark />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<Text>{formatMessage(labels.saveSegment)}</Text>
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
{canSave && (
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button variant="zero">
|
||||
<Icon>
|
||||
<Bookmark />
|
||||
</Icon>
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<Text>{formatMessage(labels.saveSegment)}</Text>
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
)}
|
||||
<Modal>
|
||||
<Dialog title={formatMessage(labels.segment)} style={{ width: 400 }}>
|
||||
{({ close }) => {
|
||||
|
|
|
|||
|
|
@ -1,37 +1,30 @@
|
|||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Row,
|
||||
Text,
|
||||
Select,
|
||||
ListItem,
|
||||
TooltipTrigger,
|
||||
Tooltip,
|
||||
} from '@umami/react-zen';
|
||||
import { Button, Icon, Row, Text, Select, ListItem } from '@umami/react-zen';
|
||||
import { isAfter } from 'date-fns';
|
||||
import { Chevron, Close, Compare } from '@/components/icons';
|
||||
import { Chevron } from '@/components/icons';
|
||||
import { useDateRange, useMessages, useNavigation } from '@/components/hooks';
|
||||
import { DateFilter } from './DateFilter';
|
||||
|
||||
export interface WebsiteDateFilterProps {
|
||||
websiteId: string;
|
||||
compare?: string;
|
||||
showAllTime?: boolean;
|
||||
showButtons?: boolean;
|
||||
allowCompare?: boolean;
|
||||
}
|
||||
|
||||
export function WebsiteDateFilter({
|
||||
websiteId,
|
||||
showAllTime = true,
|
||||
showButtons = true,
|
||||
allowCompare,
|
||||
}: {
|
||||
websiteId: string;
|
||||
compare?: string;
|
||||
showAllTime?: boolean;
|
||||
showButtons?: boolean;
|
||||
allowCompare?: boolean;
|
||||
}) {
|
||||
}: WebsiteDateFilterProps) {
|
||||
const { dateRange } = useDateRange(websiteId);
|
||||
const { value, startDate, endDate } = dateRange;
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const {
|
||||
router,
|
||||
updateParams,
|
||||
query: { compare, offset = 0 },
|
||||
query: { compare = 'prev', offset = 0 },
|
||||
} = useNavigation();
|
||||
const isAllTime = value === 'all';
|
||||
const isCustomRange = value.startsWith('range');
|
||||
|
|
@ -50,10 +43,6 @@ export function WebsiteDateFilter({
|
|||
router.push(updateParams({ compare }));
|
||||
};
|
||||
|
||||
const handleCompare = () => {
|
||||
router.push(updateParams({ compare: compare ? undefined : 'prev' }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Row gap>
|
||||
{showButtons && !isAllTime && !isCustomRange && (
|
||||
|
|
@ -78,7 +67,7 @@ export function WebsiteDateFilter({
|
|||
showAllTime={showAllTime}
|
||||
renderDate={+offset !== 0}
|
||||
/>
|
||||
{!isAllTime && compare && (
|
||||
{allowCompare && !isAllTime && (
|
||||
<Row alignItems="center" gap>
|
||||
<Text weight="bold">VS</Text>
|
||||
<Row width="200px">
|
||||
|
|
@ -93,14 +82,6 @@ export function WebsiteDateFilter({
|
|||
</Row>
|
||||
</Row>
|
||||
)}
|
||||
{!isAllTime && allowCompare && (
|
||||
<TooltipTrigger delay={0}>
|
||||
<Button variant="quiet" onPress={handleCompare}>
|
||||
<Icon fillColor>{compare ? <Close /> : <Compare />}</Icon>
|
||||
</Button>
|
||||
<Tooltip>{formatMessage(compare ? labels.cancel : labels.compareDates)}</Tooltip>
|
||||
</TooltipTrigger>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,9 +91,10 @@ export const labels = defineMessages({
|
|||
refresh: { id: 'label.refresh', defaultMessage: 'Refresh' },
|
||||
page: { id: 'label.page', defaultMessage: 'Page' },
|
||||
pages: { id: 'label.pages', defaultMessage: 'Pages' },
|
||||
entry: { id: 'label.entry', defaultMessage: 'Entry path' },
|
||||
exit: { id: 'label.exit', defaultMessage: 'Exit path' },
|
||||
entry: { id: 'label.entry', defaultMessage: 'Entry' },
|
||||
exit: { id: 'label.exit', defaultMessage: 'Exit' },
|
||||
referrers: { id: 'label.referrers', defaultMessage: 'Referrers' },
|
||||
screen: { id: 'label.screen', defaultMessage: 'Screen' },
|
||||
screens: { id: 'label.screens', defaultMessage: 'Screens' },
|
||||
browsers: { id: 'label.browsers', defaultMessage: 'Browsers' },
|
||||
os: { id: 'label.os', defaultMessage: 'OS' },
|
||||
|
|
@ -301,6 +302,7 @@ export const labels = defineMessages({
|
|||
lastSeen: { id: 'label.last-seen', defaultMessage: 'Last seen' },
|
||||
firstSeen: { id: 'label.first-seen', defaultMessage: 'First seen' },
|
||||
properties: { id: 'label.properties', defaultMessage: 'Properties' },
|
||||
channel: { id: 'label.channel', defaultMessage: 'Channel' },
|
||||
channels: { id: 'label.channels', defaultMessage: 'Channels' },
|
||||
sources: { id: 'label.sources', defaultMessage: 'Sources' },
|
||||
medium: { id: 'label.medium', defaultMessage: 'Medium' },
|
||||
|
|
@ -354,11 +356,12 @@ export const labels = defineMessages({
|
|||
destinationUrl: { id: 'label.destination-url', defaultMessage: 'Destination URL' },
|
||||
audience: { id: 'label.audience', defaultMessage: 'Audience' },
|
||||
invalidUrl: { id: 'label.invalid-url', defaultMessage: 'Invalid URL' },
|
||||
environment: { id: 'label.environment', defaultMessage: 'Environment' },
|
||||
});
|
||||
|
||||
export const messages = defineMessages({
|
||||
error: { id: 'message.error', defaultMessage: 'Something went wrong.' },
|
||||
saved: { id: 'message.saved', defaultMessage: 'Saved.' },
|
||||
saved: { id: 'message.saved', defaultMessage: 'Saved successfully.' },
|
||||
noUsers: { id: 'message.no-users', defaultMessage: 'There are no users.' },
|
||||
userDeleted: { id: 'message.user-deleted', defaultMessage: 'User deleted.' },
|
||||
noDataAvailable: { id: 'message.no-data-available', defaultMessage: 'No data available.' },
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
import { FilterLink } from '@/components/common/FilterLink';
|
||||
import { MetricsTable, MetricsTableProps } from '@/components/metrics/MetricsTable';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { useFormat } from '@/components/hooks';
|
||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
||||
|
||||
export function BrowsersTable(props: MetricsTableProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { formatBrowser } = useFormat();
|
||||
|
||||
function renderLink({ x: browser }) {
|
||||
return (
|
||||
<FilterLink id="browser" value={browser} label={formatBrowser(browser)}>
|
||||
<TypeIcon type="browser" value={browser} />
|
||||
</FilterLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.browsers)}
|
||||
type="browser"
|
||||
metric={formatMessage(labels.visitors)}
|
||||
renderLabel={renderLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { MetricsTable, MetricsTableProps } from '@/components/metrics/MetricsTable';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export function ChannelsTable(props: MetricsTableProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const renderLabel = ({ x }) => {
|
||||
return formatMessage(labels[x]);
|
||||
};
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.channels)}
|
||||
type="channel"
|
||||
renderLabel={renderLabel}
|
||||
metric={formatMessage(labels.visitors)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
||||
import { emptyFilter } from '@/lib/filters';
|
||||
import { FilterLink } from '@/components/common/FilterLink';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { useFormat } from '@/components/hooks';
|
||||
|
||||
export function CitiesTable(props: MetricsTableProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { formatCity } = useFormat();
|
||||
|
||||
const renderLink = ({ x: city, country }) => {
|
||||
return (
|
||||
<FilterLink id="city" value={city} label={formatCity(city, country)}>
|
||||
{country && (
|
||||
<img
|
||||
src={`${process.env.basePath || ''}/images/country/${
|
||||
country?.toLowerCase() || 'xx'
|
||||
}.png`}
|
||||
alt={country}
|
||||
/>
|
||||
)}
|
||||
</FilterLink>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.cities)}
|
||||
type="city"
|
||||
metric={formatMessage(labels.visitors)}
|
||||
dataFilter={emptyFilter}
|
||||
renderLabel={renderLink}
|
||||
searchFormattedValues={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,17 +3,22 @@ import { useCountryNames } from '@/components/hooks';
|
|||
import { useLocale, useMessages, useFormat } from '@/components/hooks';
|
||||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
||||
import { MetricsExpandedTable } from '@/components/metrics/MetricsExpandedTable';
|
||||
|
||||
export function CountriesTable({ ...props }: MetricsTableProps) {
|
||||
export interface CountriesTableProps extends MetricsTableProps {
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
export function CountriesTable({ isExpanded, ...props }: CountriesTableProps) {
|
||||
const { locale } = useLocale();
|
||||
const { countryNames } = useCountryNames(locale);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { formatCountry } = useFormat();
|
||||
|
||||
const renderLink = ({ x: code }) => {
|
||||
const renderLabel = ({ label: code }) => {
|
||||
return (
|
||||
<FilterLink
|
||||
id="country"
|
||||
type="country"
|
||||
value={(countryNames[code] && code) || code}
|
||||
label={formatCountry(code)}
|
||||
>
|
||||
|
|
@ -22,13 +27,15 @@ export function CountriesTable({ ...props }: MetricsTableProps) {
|
|||
);
|
||||
};
|
||||
|
||||
const Component = isExpanded ? MetricsExpandedTable : MetricsTable;
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
<Component
|
||||
{...props}
|
||||
title={formatMessage(labels.countries)}
|
||||
type="country"
|
||||
metric={formatMessage(labels.visitors)}
|
||||
renderLabel={renderLink}
|
||||
renderLabel={renderLabel}
|
||||
searchFormattedValues={true}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
||||
import { FilterLink } from '@/components/common/FilterLink';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { useFormat } from '@/components/hooks';
|
||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
||||
|
||||
export function DevicesTable(props: MetricsTableProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { formatDevice } = useFormat();
|
||||
|
||||
function renderLink({ x: device }) {
|
||||
return (
|
||||
<FilterLink id="device" value={labels[device] && device} label={formatDevice(device)}>
|
||||
<TypeIcon type="device" value={device?.toLowerCase()} />
|
||||
</FilterLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.devices)}
|
||||
type="device"
|
||||
metric={formatMessage(labels.visitors)}
|
||||
renderLabel={renderLink}
|
||||
searchFormattedValues={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export interface EventsTableProps extends MetricsTableProps {
|
||||
onLabelClick?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function EventsTable({ onLabelClick, ...props }: EventsTableProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const renderLabel = ({ x: label }) => {
|
||||
if (onLabelClick) {
|
||||
return (
|
||||
<div onClick={() => onLabelClick(label)} style={{ cursor: 'pointer' }}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return label;
|
||||
};
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.events)}
|
||||
type="event"
|
||||
metric={formatMessage(labels.actions)}
|
||||
renderLabel={renderLabel}
|
||||
allowDownload={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
||||
import { FilterLink } from '@/components/common/FilterLink';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { Flexbox } from '@umami/react-zen';
|
||||
|
||||
export function HostnamesTable(props: MetricsTableProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const renderLink = ({ x: hostname }) => {
|
||||
return (
|
||||
<Flexbox alignItems="center">
|
||||
<FilterLink
|
||||
id="hostname"
|
||||
value={hostname}
|
||||
externalUrl={`https://${hostname}`}
|
||||
label={!hostname && formatMessage(labels.none)}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.hostname)}
|
||||
type="hostname"
|
||||
metric={formatMessage(labels.visitors)}
|
||||
renderLabel={renderLink}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
||||
import { useLocale } from '@/components/hooks';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { useFormat } from '@/components/hooks';
|
||||
|
||||
export function LanguagesTable(props: MetricsTableProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { locale } = useLocale();
|
||||
const { formatLanguage } = useFormat();
|
||||
|
||||
const renderLabel = ({ x }) => {
|
||||
return <div className={locale}>{formatLanguage(x)}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.languages)}
|
||||
type="language"
|
||||
metric={formatMessage(labels.visitors)}
|
||||
renderLabel={renderLabel}
|
||||
searchFormattedValues={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 300px;
|
||||
display: block;
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ import { useMessages } from '@/components/hooks';
|
|||
import { formatShortTime } from '@/lib/format';
|
||||
import { DataColumn, DataTable } from '@umami/react-zen';
|
||||
import { ReactNode } from 'react';
|
||||
import styles from './ListExpandedTable.module.css';
|
||||
|
||||
export interface ListExpandedTableProps {
|
||||
data?: any[];
|
||||
|
|
@ -15,7 +14,7 @@ export function ListExpandedTable({ data = [], title, renderLabel }: ListExpande
|
|||
|
||||
return (
|
||||
<DataTable data={data}>
|
||||
<DataColumn id="label" label={title} align="start" className={styles.truncate}>
|
||||
<DataColumn id="label" label={title} align="start">
|
||||
{row =>
|
||||
renderLabel
|
||||
? renderLabel({ x: row?.['name'], country: row?.['country'] }, Number(row.id))
|
||||
|
|
|
|||
|
|
@ -9,13 +9,19 @@ import { formatLongCurrency, formatLongNumber } from '@/lib/format';
|
|||
|
||||
const ITEM_SIZE = 30;
|
||||
|
||||
interface ListData {
|
||||
label: string;
|
||||
count: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface ListTableProps {
|
||||
data?: any[];
|
||||
data?: ListData[];
|
||||
title?: string;
|
||||
metric?: string;
|
||||
className?: string;
|
||||
renderLabel?: (row: any, index: number) => ReactNode;
|
||||
renderChange?: (row: any, index: number) => ReactNode;
|
||||
renderLabel?: (data: ListData, index: number) => ReactNode;
|
||||
renderChange?: (data: ListData, index: number) => ReactNode;
|
||||
animate?: boolean;
|
||||
virtualize?: boolean;
|
||||
showPercentage?: boolean;
|
||||
|
|
@ -37,14 +43,14 @@ export function ListTable({
|
|||
}: ListTableProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const getRow = (row: { x: any; y: any; z: any }, index: number) => {
|
||||
const { x: label, y: value, z: percent } = row || {};
|
||||
const getRow = (row: ListData, index: number) => {
|
||||
const { label, count, percent } = row;
|
||||
|
||||
return (
|
||||
<AnimatedRow
|
||||
key={label}
|
||||
key={`${label}${index}`}
|
||||
label={renderLabel ? renderLabel(row, index) : (label ?? formatMessage(labels.unknown))}
|
||||
value={value}
|
||||
value={count}
|
||||
percent={percent}
|
||||
animate={animate && !virtualize}
|
||||
showPercentage={showPercentage}
|
||||
|
|
|
|||
155
src/components/metrics/MetricLabel.tsx
Normal file
155
src/components/metrics/MetricLabel.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { Row } from '@umami/react-zen';
|
||||
import {
|
||||
useCountryNames,
|
||||
useLocale,
|
||||
useMessages,
|
||||
useRegionNames,
|
||||
useFormat,
|
||||
} from '@/components/hooks';
|
||||
import { FilterLink } from '@/components/common/FilterLink';
|
||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
||||
import { Favicon } from '@/components/common/Favicon';
|
||||
import { GROUPED_DOMAINS } from '@/lib/constants';
|
||||
|
||||
export interface MetricLabelProps {
|
||||
type: string;
|
||||
data: any;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function MetricLabel({ type, data }: MetricLabelProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { formatValue, formatCity } = useFormat();
|
||||
const { locale } = useLocale();
|
||||
const { countryNames } = useCountryNames(locale);
|
||||
const { getRegionName } = useRegionNames(locale);
|
||||
|
||||
const { label, country, domain } = data;
|
||||
const isType = ['browser', 'country', 'device', 'os'].includes(type);
|
||||
|
||||
switch (type) {
|
||||
case 'browser':
|
||||
return (
|
||||
<FilterLink
|
||||
type="browser"
|
||||
value={label}
|
||||
label={formatValue(label, 'browser')}
|
||||
icon={<TypeIcon type="browser" value={label} />}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'channel':
|
||||
return formatMessage(labels[label]);
|
||||
|
||||
case 'city':
|
||||
return (
|
||||
<FilterLink
|
||||
type="city"
|
||||
value={label}
|
||||
label={formatCity(label, country)}
|
||||
icon={
|
||||
country && (
|
||||
<img
|
||||
src={`${process.env.basePath || ''}/images/country/${
|
||||
country?.toLowerCase() || 'xx'
|
||||
}.png`}
|
||||
alt={country}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'region':
|
||||
return (
|
||||
<FilterLink
|
||||
type="region"
|
||||
value={label}
|
||||
label={getRegionName(label, country)}
|
||||
icon={<TypeIcon type="country" value={country} />}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'country':
|
||||
return (
|
||||
<FilterLink
|
||||
type="country"
|
||||
value={(countryNames[label] && label) || label}
|
||||
label={formatValue(label, 'country')}
|
||||
icon={<TypeIcon type="country" value={label} />}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'path':
|
||||
case 'entry':
|
||||
case 'exit':
|
||||
return (
|
||||
<FilterLink
|
||||
type={type === 'entry' || type === 'exit' ? 'path' : type}
|
||||
value={label}
|
||||
label={!label && formatMessage(labels.none)}
|
||||
externalUrl={
|
||||
domain ? `${domain?.startsWith('http') ? domain : `https://${domain}`}${label}` : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'device':
|
||||
return (
|
||||
<FilterLink
|
||||
type="device"
|
||||
value={labels[label] && label}
|
||||
label={formatValue(label, 'device')}
|
||||
icon={<TypeIcon type="device" value={label?.toLowerCase()} />}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'referrer':
|
||||
return (
|
||||
<FilterLink
|
||||
type="referrer"
|
||||
value={label}
|
||||
externalUrl={`https://${label}`}
|
||||
label={!label && formatMessage(labels.none)}
|
||||
icon={<Favicon domain={label} />}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'domain':
|
||||
if (label === 'Other') {
|
||||
return `(${formatMessage(labels.other)})`;
|
||||
} else {
|
||||
const name = GROUPED_DOMAINS.find(({ domain }) => domain === label)?.name;
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row alignItems="center" gap="3">
|
||||
<Favicon domain={label} />
|
||||
{name}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
case 'language':
|
||||
return formatValue(label, 'language');
|
||||
|
||||
default:
|
||||
return (
|
||||
<FilterLink
|
||||
type={type}
|
||||
value={label}
|
||||
icon={
|
||||
isType && (
|
||||
<TypeIcon
|
||||
type={type as 'browser' | 'country' | 'device' | 'os'}
|
||||
value={label?.toLowerCase()?.replaceAll(/\W/g, '-')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
110
src/components/metrics/MetricsExpandedTable.tsx
Normal file
110
src/components/metrics/MetricsExpandedTable.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { ReactNode, useState } from 'react';
|
||||
import { Button, Column, DataColumn, DataTable, Icon, Row, SearchField } from '@umami/react-zen';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import { useMessages, useWebsiteExpandedMetricsQuery } from '@/components/hooks';
|
||||
import { Close } from '@/components/icons';
|
||||
import { DownloadButton } from '@/components/input/DownloadButton';
|
||||
import { formatShortTime } from '@/lib/format';
|
||||
import { MetricLabel } from '@/components/metrics/MetricLabel';
|
||||
|
||||
export interface MetricsExpandedTableProps {
|
||||
websiteId: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
dataFilter?: (data: any) => any;
|
||||
onSearch?: (search: string) => void;
|
||||
params?: { [key: string]: any };
|
||||
allowSearch?: boolean;
|
||||
allowDownload?: boolean;
|
||||
renderLabel?: (row: any, index: number) => ReactNode;
|
||||
onClose?: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function MetricsExpandedTable({
|
||||
websiteId,
|
||||
type,
|
||||
title,
|
||||
params,
|
||||
allowSearch = true,
|
||||
allowDownload = true,
|
||||
onClose,
|
||||
children,
|
||||
}: MetricsExpandedTableProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const isType = ['browser', 'country', 'device', 'os'].includes(type);
|
||||
|
||||
const { data, isLoading, isFetching, error } = useWebsiteExpandedMetricsQuery(websiteId, {
|
||||
type,
|
||||
search: isType ? undefined : search,
|
||||
...params,
|
||||
});
|
||||
|
||||
const items = data?.map(({ name, ...props }) => ({ label: name, ...props }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row alignItems="center" paddingBottom="3">
|
||||
{allowSearch && <SearchField value={search} onSearch={setSearch} delay={300} />}
|
||||
<Row justifyContent="flex-end" flexGrow={1} gap>
|
||||
{children}
|
||||
{allowDownload && <DownloadButton filename={type} data={data} />}
|
||||
{onClose && (
|
||||
<Button onPress={onClose} variant="quiet">
|
||||
<Icon>
|
||||
<Close />
|
||||
</Icon>
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
<LoadingPanel
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
height="100%"
|
||||
>
|
||||
<Column overflowY="auto" minHeight="0" height="100%" paddingRight="3" overflow="hidden">
|
||||
{items && (
|
||||
<DataTable data={items}>
|
||||
<DataColumn id="label" label={title} width="2fr" align="start">
|
||||
{row => (
|
||||
<Row overflow="hidden">
|
||||
<MetricLabel type={type} data={row} />
|
||||
</Row>
|
||||
)}
|
||||
</DataColumn>
|
||||
<DataColumn id="visitors" label={formatMessage(labels.visitors)} align="end">
|
||||
{row => row?.['visitors']?.toLocaleString()}
|
||||
</DataColumn>
|
||||
<DataColumn id="visits" label={formatMessage(labels.visits)} align="end">
|
||||
{row => row?.['visits']?.toLocaleString()}
|
||||
</DataColumn>
|
||||
<DataColumn id="pageviews" label={formatMessage(labels.views)} align="end">
|
||||
{row => row?.['pageviews']?.toLocaleString()}
|
||||
</DataColumn>
|
||||
<DataColumn id="bounceRate" label={formatMessage(labels.bounceRate)} align="end">
|
||||
{row => {
|
||||
const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100;
|
||||
return Math.round(+n) + '%';
|
||||
}}
|
||||
</DataColumn>
|
||||
<DataColumn
|
||||
id="visitDuration"
|
||||
label={formatMessage(labels.visitDuration)}
|
||||
align="end"
|
||||
>
|
||||
{row => {
|
||||
const n = (row?.['totaltime'] / row?.['visits']) * 100;
|
||||
return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
|
||||
}}
|
||||
</DataColumn>
|
||||
</DataTable>
|
||||
)}
|
||||
</Column>
|
||||
</LoadingPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +1,21 @@
|
|||
import { ReactNode, useMemo, useState } from 'react';
|
||||
import { Button, Column, Icon, Row, SearchField, Text, Grid } from '@umami/react-zen';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Icon, Row, Text } from '@umami/react-zen';
|
||||
import { LinkButton } from '@/components/common/LinkButton';
|
||||
import { LoadingPanel } from '@/components/common/LoadingPanel';
|
||||
import {
|
||||
useFormat,
|
||||
useMessages,
|
||||
useNavigation,
|
||||
useWebsiteExpandedMetricsQuery,
|
||||
useWebsiteMetricsQuery,
|
||||
} from '@/components/hooks';
|
||||
import { Close, Maximize } from '@/components/icons';
|
||||
import { DownloadButton } from '@/components/input/DownloadButton';
|
||||
import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants';
|
||||
import { useMessages, useNavigation, useWebsiteMetricsQuery } from '@/components/hooks';
|
||||
import { Maximize } from '@/components/icons';
|
||||
import { percentFilter } from '@/lib/filters';
|
||||
|
||||
import { ListExpandedTable, ListExpandedTableProps } from './ListExpandedTable';
|
||||
import { ListTable, ListTableProps } from './ListTable';
|
||||
import { MetricLabel } from '@/components/metrics/MetricLabel';
|
||||
|
||||
export interface MetricsTableProps extends ListTableProps {
|
||||
websiteId: string;
|
||||
type?: string;
|
||||
type: string;
|
||||
dataFilter?: (data: any) => any;
|
||||
limit?: number;
|
||||
delay?: number;
|
||||
onSearch?: (search: string) => void;
|
||||
allowSearch?: boolean;
|
||||
searchFormattedValues?: boolean;
|
||||
showMore?: boolean;
|
||||
params?: { [key: string]: any };
|
||||
allowDownload?: boolean;
|
||||
isExpanded?: boolean;
|
||||
onClose?: () => void;
|
||||
children?: ReactNode;
|
||||
params?: Record<string, any>;
|
||||
onDataLoad?: (data: any) => void;
|
||||
}
|
||||
|
||||
export function MetricsTable({
|
||||
|
|
@ -39,50 +23,18 @@ export function MetricsTable({
|
|||
type,
|
||||
dataFilter,
|
||||
limit,
|
||||
delay = null,
|
||||
allowSearch = false,
|
||||
searchFormattedValues = false,
|
||||
showMore = true,
|
||||
showMore = false,
|
||||
params,
|
||||
allowDownload = true,
|
||||
isExpanded = false,
|
||||
onClose,
|
||||
children,
|
||||
onDataLoad,
|
||||
...props
|
||||
}: MetricsTableProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const { formatValue } = useFormat();
|
||||
const { updateParams } = useNavigation();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const expandedQuery = useWebsiteExpandedMetricsQuery(
|
||||
websiteId,
|
||||
{
|
||||
type,
|
||||
search: searchFormattedValues ? undefined : search,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
retryDelay: delay || DEFAULT_ANIMATION_DURATION,
|
||||
enabled: isExpanded,
|
||||
},
|
||||
);
|
||||
|
||||
const query = useWebsiteMetricsQuery(
|
||||
websiteId,
|
||||
{
|
||||
type,
|
||||
limit,
|
||||
search: searchFormattedValues ? undefined : search,
|
||||
...params,
|
||||
},
|
||||
{
|
||||
retryDelay: delay || DEFAULT_ANIMATION_DURATION,
|
||||
enabled: !isExpanded,
|
||||
},
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, error } = isExpanded ? expandedQuery : query;
|
||||
const { data, isLoading, isFetching, error } = useWebsiteMetricsQuery(websiteId, {
|
||||
type,
|
||||
limit,
|
||||
...params,
|
||||
});
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (data) {
|
||||
|
|
@ -98,59 +50,42 @@ export function MetricsTable({
|
|||
}
|
||||
}
|
||||
|
||||
if (searchFormattedValues && search) {
|
||||
items = items.filter(({ x, ...data }) => {
|
||||
const value = formatValue(x, type, data);
|
||||
|
||||
return value?.toLowerCase().includes(search.toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
items = percentFilter(items);
|
||||
|
||||
return items;
|
||||
return items.map(({ x, y, z, ...props }) => ({ label: x, count: y, percent: z, ...props }));
|
||||
}
|
||||
return [];
|
||||
}, [data, dataFilter, search, limit, formatValue, type]);
|
||||
}, [data, dataFilter, limit, type]);
|
||||
|
||||
const downloadData = isExpanded ? data : filteredData;
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
onDataLoad?.(data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const renderLabel = (row: any) => {
|
||||
return <MetricLabel type={type} data={row} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<LoadingPanel data={data} isFetching={isFetching} isLoading={isLoading} error={error}>
|
||||
<Grid rows="40px 1fr" height="100%" overflow="hidden" gap>
|
||||
<Row alignItems="center">
|
||||
{allowSearch && <SearchField value={search} onSearch={setSearch} delay={300} />}
|
||||
<Row justifyContent="flex-end" flexGrow={1} gap>
|
||||
{children}
|
||||
{allowDownload && <DownloadButton filename={type} data={downloadData} />}
|
||||
{onClose && (
|
||||
<Button onPress={onClose} variant="quiet">
|
||||
<Icon>
|
||||
<Close />
|
||||
</Icon>
|
||||
</Button>
|
||||
)}
|
||||
</Row>
|
||||
<LoadingPanel
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
height="100%"
|
||||
>
|
||||
{data && <ListTable {...props} data={filteredData} renderLabel={renderLabel} />}
|
||||
{showMore && limit && (
|
||||
<Row justifyContent="center">
|
||||
<LinkButton href={updateParams({ view: type })} variant="quiet">
|
||||
<Icon size="sm">
|
||||
<Maximize />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.more)}</Text>
|
||||
</LinkButton>
|
||||
</Row>
|
||||
<Column overflowY="auto" minHeight="0" height="100%" paddingRight="3" overflow="hidden">
|
||||
{data &&
|
||||
(isExpanded ? (
|
||||
<ListExpandedTable {...(props as ListExpandedTableProps)} data={data} />
|
||||
) : (
|
||||
<ListTable {...(props as ListTableProps)} data={filteredData} />
|
||||
))}
|
||||
{showMore && limit && (
|
||||
<Row justifyContent="center">
|
||||
<LinkButton href={updateParams({ view: type })} variant="quiet">
|
||||
<Icon size="sm">
|
||||
<Maximize />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.more)}</Text>
|
||||
</LinkButton>
|
||||
</Row>
|
||||
)}
|
||||
</Column>
|
||||
</Grid>
|
||||
)}
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
||||
import { FilterLink } from '@/components/common/FilterLink';
|
||||
import { useMessages, useFormat } from '@/components/hooks';
|
||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
||||
|
||||
export function OSTable(props: MetricsTableProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { formatOS } = useFormat();
|
||||
|
||||
function renderLink({ x: os }) {
|
||||
return (
|
||||
<FilterLink id="os" value={os} label={formatOS(os)}>
|
||||
<TypeIcon type="os" value={os?.toLowerCase()?.replaceAll(/\W/g, '-')} />
|
||||
</FilterLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
type="os"
|
||||
title={formatMessage(labels.os)}
|
||||
metric={formatMessage(labels.visitors)}
|
||||
renderLabel={renderLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import { WebsiteContext } from '@/app/(main)/websites/[websiteId]/WebsiteProvider';
|
||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
||||
import { FilterLink } from '@/components/common/FilterLink';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { emptyFilter } from '@/lib/filters';
|
||||
import { useContext } from 'react';
|
||||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
||||
|
||||
export interface PagesTableProps extends MetricsTableProps {
|
||||
allowFilter?: boolean;
|
||||
}
|
||||
|
||||
export function PagesTable({ allowFilter, ...props }: PagesTableProps) {
|
||||
const {
|
||||
router,
|
||||
updateParams,
|
||||
query: { view = 'path' },
|
||||
} = useNavigation();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { domain } = useContext(WebsiteContext);
|
||||
|
||||
const handleChange = (id: any) => {
|
||||
router.push(updateParams({ view: id }));
|
||||
};
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
id: 'path',
|
||||
label: formatMessage(labels.path),
|
||||
},
|
||||
{
|
||||
id: 'entry',
|
||||
label: formatMessage(labels.entry),
|
||||
},
|
||||
{
|
||||
id: 'exit',
|
||||
label: formatMessage(labels.exit),
|
||||
},
|
||||
{
|
||||
id: 'title',
|
||||
label: formatMessage(labels.title),
|
||||
},
|
||||
];
|
||||
|
||||
const renderLink = ({ x }) => {
|
||||
return (
|
||||
<FilterLink
|
||||
id={view === 'entry' || view === 'exit' ? 'path' : view}
|
||||
value={x}
|
||||
label={!x && formatMessage(labels.none)}
|
||||
externalUrl={
|
||||
view !== 'title'
|
||||
? `${domain.startsWith('http') ? domain : `https://${domain}`}${x}`
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.pages)}
|
||||
type={view}
|
||||
metric={formatMessage(labels.visitors)}
|
||||
dataFilter={emptyFilter}
|
||||
renderLabel={renderLink}
|
||||
>
|
||||
{allowFilter && <FilterButtons items={buttons} value={view} onChange={handleChange} />}
|
||||
</MetricsTable>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
.item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.param {
|
||||
padding: 0 8px;
|
||||
color: var(--primary-color);
|
||||
background: var(--blue100);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.value {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Row, Text } from '@umami/react-zen';
|
||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
||||
import { emptyFilter, paramFilter } from '@/lib/filters';
|
||||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
const FILTER_COMBINED = 'filter-combined';
|
||||
const FILTER_RAW = 'filter-raw';
|
||||
|
||||
const filters = {
|
||||
[FILTER_RAW]: emptyFilter,
|
||||
[FILTER_COMBINED]: [emptyFilter, paramFilter],
|
||||
};
|
||||
|
||||
export function QueryParametersTable({
|
||||
allowFilter,
|
||||
...props
|
||||
}: { allowFilter: boolean } & MetricsTableProps) {
|
||||
const [filter, setFilter] = useState(FILTER_COMBINED);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
id: FILTER_COMBINED,
|
||||
label: formatMessage(labels.filterCombined),
|
||||
},
|
||||
{ id: FILTER_RAW, label: formatMessage(labels.filterRaw) },
|
||||
];
|
||||
|
||||
const renderLabel = ({ x, p, v }) => {
|
||||
return (
|
||||
<Row alignItems="center" maxWidth="600px" gap>
|
||||
{filter === FILTER_RAW ? (
|
||||
<Text truncate title={x}>
|
||||
{x}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text color="primary" weight="bold">
|
||||
{p}
|
||||
</Text>
|
||||
<Text truncate title={v}>
|
||||
{v}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.query)}
|
||||
type="query"
|
||||
metric={formatMessage(labels.views)}
|
||||
dataFilter={filters[filter]}
|
||||
renderLabel={renderLabel}
|
||||
delay={0}
|
||||
isExpanded={false}
|
||||
>
|
||||
{allowFilter && <FilterButtons items={buttons} value={filter} onChange={setFilter} />}
|
||||
</MetricsTable>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import { Favicon } from '@/components/common/Favicon';
|
||||
import { FilterButtons } from '@/components/input/FilterButtons';
|
||||
import { FilterLink } from '@/components/common/FilterLink';
|
||||
import { useMessages, useNavigation } from '@/components/hooks';
|
||||
import { GROUPED_DOMAINS } from '@/lib/constants';
|
||||
import { emptyFilter } from '@/lib/filters';
|
||||
import { Row } from '@umami/react-zen';
|
||||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
||||
|
||||
export interface ReferrersTableProps extends MetricsTableProps {
|
||||
allowFilter?: boolean;
|
||||
}
|
||||
|
||||
export function ReferrersTable({ allowFilter, ...props }: ReferrersTableProps) {
|
||||
const {
|
||||
router,
|
||||
updateParams,
|
||||
query: { view = 'referrer' },
|
||||
} = useNavigation();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const handleSelect = (key: any) => {
|
||||
router.push(updateParams({ view: key }));
|
||||
};
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
id: 'referrer',
|
||||
label: formatMessage(labels.domain),
|
||||
},
|
||||
{
|
||||
id: 'grouped',
|
||||
label: formatMessage(labels.grouped),
|
||||
},
|
||||
];
|
||||
|
||||
const renderLink = ({ x: referrer }) => {
|
||||
if (view === 'grouped') {
|
||||
if (referrer === 'Other') {
|
||||
return `(${formatMessage(labels.other)})`;
|
||||
} else {
|
||||
return (
|
||||
<Row alignItems="center" gap="3">
|
||||
<Favicon domain={referrer} />
|
||||
{GROUPED_DOMAINS.find(({ domain }) => domain === referrer)?.name}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterLink
|
||||
id="referrer"
|
||||
value={referrer}
|
||||
externalUrl={`https://${referrer}`}
|
||||
label={!referrer && formatMessage(labels.none)}
|
||||
>
|
||||
<Favicon domain={referrer} />
|
||||
</FilterLink>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.referrers)}
|
||||
type={view}
|
||||
metric={formatMessage(labels.visitors)}
|
||||
dataFilter={emptyFilter}
|
||||
renderLabel={renderLink}
|
||||
>
|
||||
{allowFilter && <FilterButtons items={buttons} value={view} onChange={handleSelect} />}
|
||||
</MetricsTable>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { FilterLink } from '@/components/common/FilterLink';
|
||||
import { emptyFilter } from '@/lib/filters';
|
||||
import { useMessages, useLocale, useRegionNames } from '@/components/hooks';
|
||||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
||||
import { TypeIcon } from '@/components/common/TypeIcon';
|
||||
|
||||
export function RegionsTable(props: MetricsTableProps) {
|
||||
const { locale } = useLocale();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { getRegionName } = useRegionNames(locale);
|
||||
|
||||
const renderLink = ({ x: code, country }) => {
|
||||
return (
|
||||
<FilterLink id="region" value={code} label={getRegionName(code, country)}>
|
||||
<TypeIcon type="country" value={country?.toLowerCase()} />
|
||||
</FilterLink>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.regions)}
|
||||
type="region"
|
||||
metric={formatMessage(labels.visitors)}
|
||||
dataFilter={emptyFilter}
|
||||
renderLabel={renderLink}
|
||||
searchFormattedValues={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
|
||||
export function ScreenTable(props: MetricsTableProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.screens)}
|
||||
type="screen"
|
||||
metric={formatMessage(labels.visitors)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { MetricsTable, MetricsTableProps } from './MetricsTable';
|
||||
import { FilterLink } from '@/components/common/FilterLink';
|
||||
import { useMessages } from '@/components/hooks';
|
||||
import { Flexbox } from '@umami/react-zen';
|
||||
|
||||
export function TagsTable(props: MetricsTableProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const renderLink = ({ x: tag }) => {
|
||||
return (
|
||||
<Flexbox alignItems="center">
|
||||
<FilterLink id="tag" value={tag} label={!tag && formatMessage(labels.none)} />
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(labels.tags)}
|
||||
type="tag"
|
||||
metric={formatMessage(labels.views)}
|
||||
renderLabel={renderLink}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue