This commit is contained in:
Francis Cao 2025-08-25 07:40:32 -07:00
commit 6c832bd0db
113 changed files with 1671 additions and 1335 deletions

View file

@ -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>
)}

View file

@ -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 (

View file

@ -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();

View file

@ -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 />} />;
}

View file

@ -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 }) {

View file

@ -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?.();

View 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>;
}

View file

@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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',
};

View file

@ -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?.();

View 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>;
}

View file

@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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',
};

View file

@ -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 }) {

View file

@ -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';

View file

@ -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();

View file

@ -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();

View file

@ -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>
);

View file

@ -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;

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>

View file

@ -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() {

View file

@ -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 (

View file

@ -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}>

View file

@ -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 }) => {

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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() {

View file

@ -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>

View 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>
);
}

View 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>
</>
);
}

View 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',
};

View file

@ -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]);

View file

@ -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>

View file

@ -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 = {

View file

@ -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();

View file

@ -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);

View file

@ -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?.();

View file

@ -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>

View file

@ -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]);

View file

@ -1,114 +0,0 @@
import { Row, Grid, Text } from '@umami/react-zen';
import { format, startOfDay, addHours } from 'date-fns';
import { useLocale, useMessages, useWebsiteSessionsWeeklyQuery } from '@/components/hooks';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { getDayOfWeekAsDate } from '@/lib/date';
import { Focusable, Tooltip, TooltipTrigger } from '@umami/react-zen';
export function SessionsWeekly({ websiteId }: { websiteId: string }) {
const { data, isLoading, error } = useWebsiteSessionsWeeklyQuery(websiteId);
const { dateLocale } = useLocale();
const { labels, formatMessage } = useMessages();
const { weekStartsOn } = dateLocale.options;
const daysOfWeek = Array(7)
.fill(weekStartsOn)
.map((d, i) => (d + i) % 7);
const [, max = 1] = data
? data.reduce((arr: number[], hours: number[], index: number) => {
const min = Math.min(...hours);
const max = Math.max(...hours);
if (index === 0) {
return [min, max];
}
if (min < arr[0]) {
arr[0] = min;
}
if (max > arr[1]) {
arr[1] = max;
}
return arr;
}, [])
: [];
return (
<LoadingPanel data={data} isLoading={isLoading} error={error}>
<Grid columns="repeat(8, 1fr)" gap>
{data && (
<>
<Grid rows="repeat(25, 20px)" gap="1">
<Row>&nbsp;</Row>
{Array(24)
.fill(null)
.map((_, i) => {
const label = format(addHours(startOfDay(new Date()), i), 'p', {
locale: dateLocale,
})
.replace(/\D00 ?/, '')
.toLowerCase();
return (
<Row key={i} justifyContent="flex-end">
<Text color="muted" weight="bold">
{label}
</Text>
</Row>
);
})}
</Grid>
{daysOfWeek.map((index: number) => {
const day = data[index];
return (
<Grid
rows="repeat(24, 20px)"
justifyContent="center"
alignItems="center"
key={index}
gap="1"
>
<Row alignItems="center" justifyContent="center" marginBottom="3">
<Text weight="bold" align="center">
{format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })}
</Text>
</Row>
{day?.map((count: number, j) => {
const pct = max ? count / max : 0;
return (
<TooltipTrigger key={j} delay={0} isDisabled={count <= 0}>
<Focusable>
<Row
alignItems="center"
justifyContent="center"
backgroundColor="2"
width="20px"
height="20px"
borderRadius="full"
style={{ margin: '0 auto' }}
>
<Row
backgroundColor="primary"
width="20px"
height="20px"
borderRadius="full"
style={{ opacity: pct, transform: `scale(${pct})` }}
/>
</Row>
</Focusable>
<Tooltip placement="right">{`${formatMessage(
labels.visitors,
)}: ${count}`}</Tooltip>
</TooltipTrigger>
);
})}
</Grid>
);
})}
</>
)}
</Grid>
</LoadingPanel>
);
}

View file

@ -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();

View file

@ -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 (

View file

@ -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 />} />;
}

View file

@ -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>