mirror of
https://github.com/umami-software/umami.git
synced 2026-02-19 12:05:41 +01:00
sync umami
This commit is contained in:
commit
cc4b21a070
600 changed files with 10884 additions and 3381 deletions
|
|
@ -21,8 +21,12 @@ export function TestConsole({ websiteId }: { websiteId: string }) {
|
|||
router.push(`/console/${value}`);
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
window['umami'].track({ url: '/page-view', referrer: 'https://www.google.com' });
|
||||
function handleRunScript() {
|
||||
window['umami'].track(props => ({
|
||||
...props,
|
||||
url: '/page-view',
|
||||
referrer: 'https://www.google.com',
|
||||
}));
|
||||
window['umami'].track('track-event-no-data');
|
||||
window['umami'].track('track-event-with-data', {
|
||||
test: 'test-data',
|
||||
|
|
@ -44,7 +48,7 @@ export function TestConsole({ websiteId }: { websiteId: string }) {
|
|||
});
|
||||
}
|
||||
|
||||
function handleIdentifyClick() {
|
||||
function handleRunIdentify() {
|
||||
window['umami'].identify({
|
||||
userId: 123,
|
||||
name: 'brian',
|
||||
|
|
@ -145,10 +149,10 @@ export function TestConsole({ websiteId }: { websiteId: string }) {
|
|||
</div>
|
||||
<div className={styles.group}>
|
||||
<div className={styles.header}>Javascript events</div>
|
||||
<Button id="manual-button" variant="primary" onClick={handleClick}>
|
||||
<Button id="manual-button" variant="primary" onClick={handleRunScript}>
|
||||
Run script
|
||||
</Button>
|
||||
<Button id="manual-button" variant="primary" onClick={handleIdentifyClick}>
|
||||
<Button id="manual-button" variant="primary" onClick={handleRunIdentify}>
|
||||
Run identify
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,5 +17,6 @@
|
|||
grid-row: 2 / 3;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 60px);
|
||||
height: calc(100dvh - 60px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,8 @@ export default function ReportsDataTable({
|
|||
}) {
|
||||
const queryResult = useReports({ websiteId, teamId });
|
||||
|
||||
if (queryResult?.result?.data?.length === 0) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable queryResult={queryResult}>
|
||||
<DataTable queryResult={queryResult} renderEmpty={() => children}>
|
||||
{({ data }) => <ReportsTable data={data} showDomain={!websiteId} />}
|
||||
</DataTable>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { GridColumn, GridTable, Icon, Icons, Text, useBreakpoint } from 'react-basics';
|
||||
import { GridColumn, GridTable, Icon, Icons, Text } from 'react-basics';
|
||||
import LinkButton from 'components/common/LinkButton';
|
||||
import { useMessages, useLogin, useTeamUrl } from 'components/hooks';
|
||||
import { REPORT_TYPES } from 'lib/constants';
|
||||
|
|
@ -7,11 +7,10 @@ import ReportDeleteButton from './ReportDeleteButton';
|
|||
export function ReportsTable({ data = [], showDomain }: { data: any[]; showDomain?: boolean }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { user } = useLogin();
|
||||
const breakpoint = useBreakpoint();
|
||||
const { renderTeamUrl } = useTeamUrl();
|
||||
|
||||
return (
|
||||
<GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
|
||||
<GridTable data={data}>
|
||||
<GridColumn name="name" label={formatMessage(labels.name)} />
|
||||
<GridColumn name="description" label={formatMessage(labels.description)} />
|
||||
<GridColumn name="type" label={formatMessage(labels.type)}>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Form,
|
||||
FormRow,
|
||||
Item,
|
||||
Flexbox,
|
||||
Dropdown,
|
||||
Button,
|
||||
SearchField,
|
||||
TextField,
|
||||
Text,
|
||||
Icon,
|
||||
Icons,
|
||||
Menu,
|
||||
Loading,
|
||||
} from 'react-basics';
|
||||
import { useMessages, useFilters, useFormat, useLocale, useWebsiteValues } from 'components/hooks';
|
||||
import { useFilters, useFormat, useLocale, useMessages, useWebsiteValues } from 'components/hooks';
|
||||
import { OPERATORS } from 'lib/constants';
|
||||
import { isEqualsOperator } from 'lib/params';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Flexbox,
|
||||
Form,
|
||||
FormRow,
|
||||
Icon,
|
||||
Icons,
|
||||
Item,
|
||||
Loading,
|
||||
Menu,
|
||||
SearchField,
|
||||
Text,
|
||||
TextField,
|
||||
} from 'react-basics';
|
||||
import styles from './FieldFilterEditForm.module.css';
|
||||
|
||||
export interface FieldFilterFormProps {
|
||||
|
|
@ -69,6 +69,16 @@ export default function FieldFilterEditForm({
|
|||
search,
|
||||
});
|
||||
|
||||
const filterDropdownItems = (name: string) => {
|
||||
const limitedFilters = ['country', 'region', 'city'];
|
||||
|
||||
if (limitedFilters.includes(name)) {
|
||||
return filters.filter(f => f.type === type && !f.label.match(/contain/gi));
|
||||
} else {
|
||||
return filters.filter(f => f.type === type);
|
||||
}
|
||||
};
|
||||
|
||||
const formattedValues = useMemo(() => {
|
||||
if (!values) {
|
||||
return {};
|
||||
|
|
@ -142,7 +152,7 @@ export default function FieldFilterEditForm({
|
|||
{allowFilterSelect && (
|
||||
<Dropdown
|
||||
className={styles.dropdown}
|
||||
items={filters.filter(f => f.type === type)}
|
||||
items={filterDropdownItems(name)}
|
||||
value={operator}
|
||||
renderValue={renderFilterValue}
|
||||
onChange={handleOperatorChange}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,12 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean })
|
|||
url: renderTeamUrl('/reports/journey'),
|
||||
icon: <Path />,
|
||||
},
|
||||
// {
|
||||
// title: formatMessage(labels.revenue),
|
||||
// description: formatMessage(labels.revenueDescription),
|
||||
// url: renderTeamUrl('/reports/revenue'),
|
||||
// icon: <Money />,
|
||||
// },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export function EventDataParameters() {
|
|||
groups,
|
||||
};
|
||||
|
||||
const handleSubmit = values => {
|
||||
const handleSubmit = (values: any) => {
|
||||
runReport(values);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@
|
|||
background-color: var(--base100);
|
||||
}
|
||||
|
||||
.step:last-child::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export function RetentionTable({ days = DAYS }) {
|
|||
{rows.map(({ date, visitors, records }, rowIndex) => {
|
||||
return (
|
||||
<div key={rowIndex} className={styles.row}>
|
||||
<div className={styles.date}>{formatDate(`${date} 00:00:00`, 'PP', locale)}</div>
|
||||
<div className={styles.date}>{formatDate(date, 'PP', locale)}</div>
|
||||
<div className={styles.visitors}>{visitors}</div>
|
||||
{days.map(day => {
|
||||
if (totalDays - rowIndex < day) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import Report from '../[reportId]/Report';
|
|||
import ReportHeader from '../[reportId]/ReportHeader';
|
||||
import ReportMenu from '../[reportId]/ReportMenu';
|
||||
import ReportBody from '../[reportId]/ReportBody';
|
||||
import Target from 'assets/target.svg';
|
||||
import Money from 'assets/money.svg';
|
||||
import { REPORT_TYPES } from 'lib/constants';
|
||||
|
||||
const defaultParameters = {
|
||||
|
|
@ -15,12 +15,12 @@ const defaultParameters = {
|
|||
export default function RevenueReport({ reportId }: { reportId?: string }) {
|
||||
return (
|
||||
<Report reportId={reportId} defaultParameters={defaultParameters}>
|
||||
<ReportHeader icon={<Target />} />
|
||||
<ReportHeader icon={<Money />} />
|
||||
<ReportMenu>
|
||||
<RevenueParameters />
|
||||
</ReportMenu>
|
||||
<ReportBody>
|
||||
<RevenueChart />
|
||||
<RevenueChart unit="day" />
|
||||
</ReportBody>
|
||||
</Report>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,12 +15,8 @@ export function TeamsDataTable({
|
|||
const { user } = useLogin();
|
||||
const queryResult = useTeams(user.id);
|
||||
|
||||
if (queryResult?.result?.data?.length === 0) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable queryResult={queryResult}>
|
||||
<DataTable queryResult={queryResult} renderEmpty={() => children}>
|
||||
{({ data }) => {
|
||||
return <TeamsTable data={data} allowEdit={allowEdit} showActions={showActions} />;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { GridColumn, GridTable, Icon, Text, useBreakpoint } from 'react-basics';
|
||||
import { GridColumn, GridTable, Icon, Text } from 'react-basics';
|
||||
import { useMessages } from 'components/hooks';
|
||||
import Icons from 'components/icons';
|
||||
import { ROLES } from 'lib/constants';
|
||||
|
|
@ -13,10 +13,9 @@ export function TeamsTable({
|
|||
showActions?: boolean;
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
return (
|
||||
<GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
|
||||
<GridTable data={data}>
|
||||
<GridColumn name="name" label={formatMessage(labels.name)} />
|
||||
<GridColumn name="owner" label={formatMessage(labels.owner)}>
|
||||
{row => row.teamUser.find(({ role }) => role === ROLES.teamOwner)?.user?.username}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,8 @@ export function UsersDataTable({
|
|||
}) {
|
||||
const queryResult = useUsers();
|
||||
|
||||
if (queryResult?.result?.data?.length === 0) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable queryResult={queryResult}>
|
||||
<DataTable queryResult={queryResult} renderEmpty={() => children}>
|
||||
{({ data }) => <UsersTable data={data} showActions={showActions} />}
|
||||
</DataTable>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Text, Icon, Icons, GridTable, GridColumn, useBreakpoint } from 'react-basics';
|
||||
import { Text, Icon, Icons, GridTable, GridColumn } from 'react-basics';
|
||||
import { formatDistance } from 'date-fns';
|
||||
import { ROLES } from 'lib/constants';
|
||||
import { useMessages, useLocale } from 'components/hooks';
|
||||
|
|
@ -14,10 +14,9 @@ export function UsersTable({
|
|||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { dateLocale } = useLocale();
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
return (
|
||||
<GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
|
||||
<GridTable data={data}>
|
||||
<GridColumn name="username" label={formatMessage(labels.username)} style={{ minWidth: 0 }} />
|
||||
<GridColumn name="role" label={formatMessage(labels.role)} width={'120px'}>
|
||||
{row =>
|
||||
|
|
|
|||
|
|
@ -18,12 +18,8 @@ export function WebsitesDataTable({
|
|||
}) {
|
||||
const queryResult = useWebsites({ teamId });
|
||||
|
||||
if (queryResult?.result?.data?.length === 0) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable queryResult={queryResult}>
|
||||
<DataTable queryResult={queryResult} renderEmpty={() => children}>
|
||||
{({ data }) => (
|
||||
<WebsitesTable
|
||||
teamId={teamId}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ReactNode } from 'react';
|
||||
import { Text, Icon, Icons, GridTable, GridColumn, useBreakpoint } from 'react-basics';
|
||||
import { Text, Icon, Icons, GridTable, GridColumn } from 'react-basics';
|
||||
import { useMessages, useTeamUrl } from 'components/hooks';
|
||||
import LinkButton from 'components/common/LinkButton';
|
||||
|
||||
|
|
@ -20,7 +20,6 @@ export function WebsitesTable({
|
|||
children,
|
||||
}: WebsitesTableProps) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const breakpoint = useBreakpoint();
|
||||
const { renderTeamUrl } = useTeamUrl();
|
||||
|
||||
if (!data?.length) {
|
||||
|
|
@ -28,7 +27,7 @@ export function WebsitesTable({
|
|||
}
|
||||
|
||||
return (
|
||||
<GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
|
||||
<GridTable data={data}>
|
||||
<GridColumn name="name" label={formatMessage(labels.name)} />
|
||||
<GridColumn name="domain" label={formatMessage(labels.domain)} />
|
||||
{showActions && (
|
||||
|
|
|
|||
|
|
@ -13,11 +13,18 @@ export function WebsiteData({ websiteId, onSave }: { websiteId: string; onSave?:
|
|||
const { teamId, renderTeamUrl } = useTeamUrl();
|
||||
const router = useRouter();
|
||||
const { result } = useTeams(user.id);
|
||||
const hasTeams = result?.data?.length > 0;
|
||||
const isTeamOwner =
|
||||
(!teamId && hasTeams) ||
|
||||
(hasTeams &&
|
||||
result?.data
|
||||
const canTransferWebsite =
|
||||
(
|
||||
!teamId &&
|
||||
result.data.filter(({ teamUser }) =>
|
||||
teamUser.find(
|
||||
({ role, userId }) =>
|
||||
[ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id,
|
||||
),
|
||||
)
|
||||
).length > 0 ||
|
||||
(teamId &&
|
||||
!!result?.data
|
||||
?.find(({ id }) => id === teamId)
|
||||
?.teamUser.find(({ role, userId }) => role === ROLES.teamOwner && userId === user.id));
|
||||
|
||||
|
|
@ -37,8 +44,8 @@ export function WebsiteData({ websiteId, onSave }: { websiteId: string; onSave?:
|
|||
label={formatMessage(labels.transferWebsite)}
|
||||
description={formatMessage(messages.transferWebsite)}
|
||||
>
|
||||
<ModalTrigger disabled={!isTeamOwner}>
|
||||
<Button variant="secondary" disabled={!isTeamOwner}>
|
||||
<ModalTrigger disabled={!canTransferWebsite}>
|
||||
<Button variant="secondary" disabled={!canTransferWebsite}>
|
||||
{formatMessage(labels.transfer)}
|
||||
</Button>
|
||||
<Modal title={formatMessage(labels.transferWebsite)}>
|
||||
|
|
|
|||
|
|
@ -71,7 +71,8 @@ export function WebsiteTransferForm({
|
|||
{result.data
|
||||
.filter(({ teamUser }) =>
|
||||
teamUser.find(
|
||||
({ role, userId }) => role === ROLES.teamOwner && userId === user.id,
|
||||
({ role, userId }) =>
|
||||
[ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id,
|
||||
),
|
||||
)
|
||||
.map(({ id, name }) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { GridColumn, GridTable, useBreakpoint } from 'react-basics';
|
||||
import { GridColumn, GridTable } from 'react-basics';
|
||||
import { useMessages, useLogin } from 'components/hooks';
|
||||
import { ROLES } from 'lib/constants';
|
||||
import TeamMemberRemoveButton from './TeamMemberRemoveButton';
|
||||
|
|
@ -15,7 +15,6 @@ export function TeamMembersTable({
|
|||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { user } = useLogin();
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
const roles = {
|
||||
[ROLES.teamOwner]: formatMessage(labels.teamOwner),
|
||||
|
|
@ -25,7 +24,7 @@ export function TeamMembersTable({
|
|||
};
|
||||
|
||||
return (
|
||||
<GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
|
||||
<GridTable data={data}>
|
||||
<GridColumn name="username" label={formatMessage(labels.username)}>
|
||||
{row => row?.user?.username}
|
||||
</GridColumn>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { GridColumn, GridTable, Icon, Text, useBreakpoint } from 'react-basics';
|
||||
import { GridColumn, GridTable, Icon, Text } from 'react-basics';
|
||||
import { useLogin, useMessages } from 'components/hooks';
|
||||
import Icons from 'components/icons';
|
||||
import LinkButton from 'components/common/LinkButton';
|
||||
|
|
@ -14,10 +14,9 @@ export function TeamWebsitesTable({
|
|||
}) {
|
||||
const { user } = useLogin();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const breakpoint = useBreakpoint();
|
||||
|
||||
return (
|
||||
<GridTable data={data} cardMode={['xs', 'sm', 'md'].includes(breakpoint)}>
|
||||
<GridTable data={data}>
|
||||
<GridColumn name="name" label={formatMessage(labels.name)} />
|
||||
<GridColumn name="domain" label={formatMessage(labels.domain)} />
|
||||
<GridColumn name="createdBy" label={formatMessage(labels.createdBy)}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useMemo } from 'react';
|
||||
import PageviewsChart from 'components/metrics/PageviewsChart';
|
||||
import { getDateArray } from 'lib/date';
|
||||
import useWebsitePageviews from 'components/hooks/queries/useWebsitePageviews';
|
||||
import { useDateRange } from 'components/hooks';
|
||||
|
||||
|
|
@ -19,8 +18,8 @@ export function WebsiteChart({
|
|||
const chartData = useMemo(() => {
|
||||
if (data) {
|
||||
const result = {
|
||||
pageviews: getDateArray(pageviews, startDate, endDate, unit),
|
||||
sessions: getDateArray(sessions, startDate, endDate, unit),
|
||||
pageviews,
|
||||
sessions,
|
||||
};
|
||||
|
||||
if (compare) {
|
||||
|
|
@ -43,7 +42,15 @@ export function WebsiteChart({
|
|||
return { pageviews: [], sessions: [] };
|
||||
}, [data, startDate, endDate, unit]);
|
||||
|
||||
return <PageviewsChart data={chartData} unit={unit} isLoading={isLoading} />;
|
||||
return (
|
||||
<PageviewsChart
|
||||
data={chartData}
|
||||
minDate={startDate.toISOString()}
|
||||
maxDate={endDate.toISOString()}
|
||||
unit={unit}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebsiteChart;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import WebsiteExpandedView from './WebsiteExpandedView';
|
|||
import WebsiteHeader from './WebsiteHeader';
|
||||
import WebsiteMetricsBar from './WebsiteMetricsBar';
|
||||
import WebsiteTableView from './WebsiteTableView';
|
||||
import WebsiteProvider from './WebsiteProvider';
|
||||
import { FILTER_COLUMNS } from 'lib/constants';
|
||||
|
||||
export default function WebsiteDetailsPage({ websiteId }: { websiteId: string }) {
|
||||
|
|
@ -25,13 +24,13 @@ export default function WebsiteDetailsPage({ websiteId }: { websiteId: string })
|
|||
}, {});
|
||||
|
||||
return (
|
||||
<WebsiteProvider websiteId={websiteId}>
|
||||
<>
|
||||
<WebsiteHeader websiteId={websiteId} showLinks={showLinks} />
|
||||
<FilterTags websiteId={websiteId} params={params} />
|
||||
<WebsiteMetricsBar websiteId={websiteId} showFilter={true} showChange={true} sticky={true} />
|
||||
<WebsiteChart websiteId={websiteId} />
|
||||
{!view && <WebsiteTableView websiteId={websiteId} />}
|
||||
{view && <WebsiteExpandedView websiteId={websiteId} />}
|
||||
</WebsiteProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
}
|
||||
|
||||
.back {
|
||||
align-self: start;
|
||||
align-self: flex-start;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
.dropdown {
|
||||
display: flex;
|
||||
width: 200px;
|
||||
align-self: end;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.menu {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr max-content;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 20px 0px;
|
||||
}
|
||||
|
||||
.title {
|
||||
|
|
@ -12,7 +14,7 @@
|
|||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
height: 100px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
|
@ -22,6 +24,7 @@
|
|||
justify-content: flex-end;
|
||||
gap: 30px;
|
||||
min-height: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.selected {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import Link from 'next/link';
|
|||
import { usePathname } from 'next/navigation';
|
||||
import { ReactNode } from 'react';
|
||||
import { Button, Icon, Text } from 'react-basics';
|
||||
import Lightning from 'assets/lightning.svg';
|
||||
import styles from './WebsiteHeader.module.css';
|
||||
|
||||
export function WebsiteHeader({
|
||||
|
|
@ -31,25 +32,30 @@ export function WebsiteHeader({
|
|||
path: '',
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.compare),
|
||||
icon: <Icons.Compare />,
|
||||
path: '/compare',
|
||||
label: formatMessage(labels.events),
|
||||
icon: <Lightning />,
|
||||
path: '/events',
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.sessions),
|
||||
icon: <Icons.User />,
|
||||
path: '/sessions',
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.realtime),
|
||||
icon: <Icons.Clock />,
|
||||
path: '/realtime',
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.compare),
|
||||
icon: <Icons.Compare />,
|
||||
path: '/compare',
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.reports),
|
||||
icon: <Icons.Reports />,
|
||||
path: '/reports',
|
||||
},
|
||||
{
|
||||
label: formatMessage(labels.eventData),
|
||||
icon: <Icons.Nodes />,
|
||||
path: '/event-data',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -64,7 +70,7 @@ export function WebsiteHeader({
|
|||
<div className={styles.links}>
|
||||
{links.map(({ label, icon, path }) => {
|
||||
const selected = path
|
||||
? pathname.endsWith(path)
|
||||
? pathname.includes(path)
|
||||
: pathname.match(/^\/websites\/[\w-]+$/);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
'use client';
|
||||
import { createContext, ReactNode, useEffect } from 'react';
|
||||
import { useModified, useWebsite } from 'components/hooks';
|
||||
import { Loading } from 'react-basics';
|
||||
|
|
|
|||
|
|
@ -1,22 +1,23 @@
|
|||
import { useState } from 'react';
|
||||
import { Grid, GridRow } from 'components/layout/Grid';
|
||||
import BrowsersTable from 'components/metrics/BrowsersTable';
|
||||
import CountriesTable from 'components/metrics/CountriesTable';
|
||||
import DevicesTable from 'components/metrics/DevicesTable';
|
||||
import EventsChart from 'components/metrics/EventsChart';
|
||||
import EventsTable from 'components/metrics/EventsTable';
|
||||
import HostsTable from 'components/metrics/HostsTable';
|
||||
import OSTable from 'components/metrics/OSTable';
|
||||
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';
|
||||
import EventsTable from 'components/metrics/EventsTable';
|
||||
import EventsChart from 'components/metrics/EventsChart';
|
||||
import HostsTable from 'components/metrics/HostsTable';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export default function WebsiteTableView({ websiteId }: { websiteId: string }) {
|
||||
const [countryData, setCountryData] = useState();
|
||||
const pathname = usePathname();
|
||||
const tableProps = {
|
||||
websiteId,
|
||||
limit: 10,
|
||||
};
|
||||
const isSharePage = pathname.includes('/share/');
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
|
|
@ -30,16 +31,18 @@ export default function WebsiteTableView({ websiteId }: { websiteId: string }) {
|
|||
<DevicesTable {...tableProps} />
|
||||
</GridRow>
|
||||
<GridRow columns="two-one">
|
||||
<WorldMap data={countryData} />
|
||||
<CountriesTable {...tableProps} onDataLoad={setCountryData} />
|
||||
</GridRow>
|
||||
<GridRow columns="one-two">
|
||||
<EventsTable {...tableProps} />
|
||||
<EventsChart websiteId={websiteId} />
|
||||
<WorldMap websiteId={websiteId} />
|
||||
<CountriesTable {...tableProps} />
|
||||
</GridRow>
|
||||
<GridRow columns="three">
|
||||
<HostsTable {...tableProps} />
|
||||
</GridRow>
|
||||
{isSharePage && (
|
||||
<GridRow columns="one-two">
|
||||
<EventsTable {...tableProps} />
|
||||
<EventsChart websiteId={websiteId} />
|
||||
</GridRow>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { useNavigation } from 'components/hooks';
|
|||
import { FILTER_COLUMNS } from 'lib/constants';
|
||||
import WebsiteChart from '../WebsiteChart';
|
||||
import WebsiteCompareTables from './WebsiteCompareTables';
|
||||
import WebsiteProvider from '../WebsiteProvider';
|
||||
|
||||
export function WebsiteComparePage({ websiteId }) {
|
||||
const { query } = useNavigation();
|
||||
|
|
@ -19,13 +18,13 @@ export function WebsiteComparePage({ websiteId }) {
|
|||
}, {});
|
||||
|
||||
return (
|
||||
<WebsiteProvider websiteId={websiteId}>
|
||||
<>
|
||||
<WebsiteHeader websiteId={websiteId} />
|
||||
<FilterTags websiteId={websiteId} params={params} />
|
||||
<WebsiteMetricsBar websiteId={websiteId} compareMode={true} showFilter={true} />
|
||||
<WebsiteChart websiteId={websiteId} compareMode={true} />
|
||||
<WebsiteCompareTables websiteId={websiteId} />
|
||||
</WebsiteProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
min-height: 90px;
|
||||
margin-bottom: 20px;
|
||||
background: var(--base50);
|
||||
z-index: var(--z-index-above);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.container {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { useApi, useDateRange, useMessages } from 'components/hooks';
|
||||
import MetricCard from 'components/metrics/MetricCard';
|
||||
import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
|
||||
import MetricsBar from 'components/metrics/MetricsBar';
|
||||
import styles from './EventDataMetricsBar.module.css';
|
||||
|
||||
export function EventDataMetricsBar({ websiteId }: { websiteId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { get, useQuery } = useApi();
|
||||
const { dateRange } = useDateRange(websiteId);
|
||||
const { startDate, endDate } = dateRange;
|
||||
|
||||
const { data, error, isLoading, isFetched } = useQuery({
|
||||
queryKey: ['event-data:stats', { websiteId, startDate, endDate }],
|
||||
queryFn: () =>
|
||||
get(`/event-data/stats`, {
|
||||
websiteId,
|
||||
startAt: +startDate,
|
||||
endAt: +endDate,
|
||||
}),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<MetricsBar isLoading={isLoading} isFetched={isFetched} error={error}>
|
||||
<MetricCard label={formatMessage(labels.events)} value={data?.events} />
|
||||
<MetricCard label={formatMessage(labels.fields)} value={data?.fields} />
|
||||
<MetricCard label={formatMessage(labels.totalRecords)} value={data?.records} />
|
||||
</MetricsBar>
|
||||
<div className={styles.actions}>
|
||||
<WebsiteDateFilter websiteId={websiteId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventDataMetricsBar;
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
'use client';
|
||||
import WebsiteHeader from '../WebsiteHeader';
|
||||
import WebsiteEventData from './WebsiteEventData';
|
||||
|
||||
export default function EventDataPage({ websiteId }) {
|
||||
return (
|
||||
<>
|
||||
<WebsiteHeader websiteId={websiteId} />
|
||||
<WebsiteEventData websiteId={websiteId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import Link from 'next/link';
|
||||
import { GridTable, GridColumn } from 'react-basics';
|
||||
import { useMessages, useNavigation } from 'components/hooks';
|
||||
import Empty from 'components/common/Empty';
|
||||
import { DATA_TYPES } from 'lib/constants';
|
||||
|
||||
export function EventDataTable({ data = [] }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { renderUrl } = useNavigation();
|
||||
|
||||
if (data.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GridTable data={data}>
|
||||
<GridColumn name="eventName" label={formatMessage(labels.event)}>
|
||||
{row => (
|
||||
<Link href={renderUrl({ event: row.eventName })} shallow={true}>
|
||||
{row.eventName}
|
||||
</Link>
|
||||
)}
|
||||
</GridColumn>
|
||||
<GridColumn name="fieldName" label={formatMessage(labels.field)}>
|
||||
{row => row.fieldName}
|
||||
</GridColumn>
|
||||
<GridColumn name="dataType" label={formatMessage(labels.type)}>
|
||||
{row => DATA_TYPES[row.dataType]}
|
||||
</GridColumn>
|
||||
<GridColumn name="total" label={formatMessage(labels.totalRecords)}>
|
||||
{({ total }) => total.toLocaleString()}
|
||||
</GridColumn>
|
||||
</GridTable>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventDataTable;
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { GridTable, GridColumn } from 'react-basics';
|
||||
import { useMessages } from 'components/hooks';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import Empty from 'components/common/Empty';
|
||||
import { DATA_TYPES } from 'lib/constants';
|
||||
|
||||
export function EventDataValueTable({ data = [], event }: { data: any[]; event: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={event} />
|
||||
{data.length <= 0 && <Empty />}
|
||||
{data.length > 0 && (
|
||||
<GridTable data={data}>
|
||||
<GridColumn name="fieldName" label={formatMessage(labels.field)} />
|
||||
<GridColumn name="dataType" label={formatMessage(labels.type)}>
|
||||
{row => DATA_TYPES[row.dataType]}
|
||||
</GridColumn>
|
||||
<GridColumn name="fieldValue" label={formatMessage(labels.value)} />
|
||||
<GridColumn name="total" label={formatMessage(labels.totalRecords)} width="200px">
|
||||
{({ total }) => total.toLocaleString()}
|
||||
</GridColumn>
|
||||
</GridTable>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventDataValueTable;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
.container a {
|
||||
color: var(--font-color100);
|
||||
}
|
||||
|
||||
.container a:hover {
|
||||
color: var(--primary400);
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import { Flexbox, Loading } from 'react-basics';
|
||||
import EventDataTable from './EventDataTable';
|
||||
import EventDataValueTable from './EventDataValueTable';
|
||||
import { EventDataMetricsBar } from './EventDataMetricsBar';
|
||||
import { useDateRange, useApi, useNavigation } from 'components/hooks';
|
||||
import styles from './WebsiteEventData.module.css';
|
||||
|
||||
function useData(websiteId: string, event: string) {
|
||||
const { dateRange } = useDateRange(websiteId);
|
||||
const { startDate, endDate } = dateRange;
|
||||
const { get, useQuery } = useApi();
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['event-data:events', { websiteId, startDate, endDate, event }],
|
||||
queryFn: () =>
|
||||
get('/event-data/events', {
|
||||
websiteId,
|
||||
startAt: +startDate,
|
||||
endAt: +endDate,
|
||||
event,
|
||||
}),
|
||||
enabled: !!(websiteId && startDate && endDate),
|
||||
});
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export default function WebsiteEventData({ websiteId }) {
|
||||
const {
|
||||
query: { event },
|
||||
} = useNavigation();
|
||||
const { data, isLoading } = useData(websiteId, event);
|
||||
|
||||
return (
|
||||
<Flexbox className={styles.container} direction="column" gap={20}>
|
||||
<EventDataMetricsBar websiteId={websiteId} />
|
||||
{!event && <EventDataTable data={data} />}
|
||||
{isLoading && <Loading position="page" />}
|
||||
{event && data && <EventDataValueTable event={event} data={data} />}
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
||||
gap: 60px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.table {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
cursor: pointer;
|
||||
color: var(--primary400);
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.chart {
|
||||
min-height: 620px;
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { GridColumn, GridTable } from 'react-basics';
|
||||
import { useEventDataProperties, useEventDataValues, useMessages } from 'components/hooks';
|
||||
import { LoadingPanel } from 'components/common/LoadingPanel';
|
||||
import PieChart from 'components/charts/PieChart';
|
||||
import { useState } from 'react';
|
||||
import { CHART_COLORS } from 'lib/constants';
|
||||
import styles from './EventProperties.module.css';
|
||||
|
||||
export function EventProperties({ websiteId }: { websiteId: string }) {
|
||||
const [propertyName, setPropertyName] = useState('');
|
||||
const [eventName, setEventName] = useState('');
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { data, isLoading, isFetched, error } = useEventDataProperties(websiteId);
|
||||
const { data: values } = useEventDataValues(websiteId, eventName, propertyName);
|
||||
const chartData =
|
||||
propertyName && values
|
||||
? {
|
||||
labels: values.map(({ value }) => value),
|
||||
datasets: [
|
||||
{
|
||||
data: values.map(({ total }) => total),
|
||||
backgroundColor: CHART_COLORS,
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
const handleRowClick = row => {
|
||||
setEventName(row.eventName);
|
||||
setPropertyName(row.propertyName);
|
||||
};
|
||||
|
||||
return (
|
||||
<LoadingPanel isLoading={isLoading} isFetched={isFetched} data={data} error={error}>
|
||||
<div className={styles.container}>
|
||||
<GridTable data={data} cardMode={false} className={styles.table}>
|
||||
<GridColumn name="eventName" label={formatMessage(labels.name)}>
|
||||
{row => (
|
||||
<div className={styles.link} onClick={() => handleRowClick(row)}>
|
||||
{row.eventName}
|
||||
</div>
|
||||
)}
|
||||
</GridColumn>
|
||||
<GridColumn name="propertyName" label={formatMessage(labels.property)}>
|
||||
{row => (
|
||||
<div className={styles.link} onClick={() => handleRowClick(row)}>
|
||||
{row.propertyName}
|
||||
</div>
|
||||
)}
|
||||
</GridColumn>
|
||||
<GridColumn name="total" label={formatMessage(labels.count)} alignment="end" />
|
||||
</GridTable>
|
||||
{propertyName && (
|
||||
<div className={styles.chart}>
|
||||
<div className={styles.title}>{propertyName}</div>
|
||||
<PieChart key={propertyName} type="doughnut" data={chartData} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventProperties;
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { useWebsiteEvents } from 'components/hooks';
|
||||
import EventsTable from './EventsTable';
|
||||
import DataTable from 'components/common/DataTable';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function EventsDataTable({
|
||||
websiteId,
|
||||
}: {
|
||||
websiteId?: string;
|
||||
teamId?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const queryResult = useWebsiteEvents(websiteId);
|
||||
|
||||
return (
|
||||
<DataTable queryResult={queryResult} allowSearch={true}>
|
||||
{({ data }) => <EventsTable data={data} />}
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { useMessages } from 'components/hooks';
|
||||
import useWebsiteSessionStats from 'components/hooks/queries/useWebsiteSessionStats';
|
||||
import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
|
||||
import MetricCard from 'components/metrics/MetricCard';
|
||||
import MetricsBar from 'components/metrics/MetricsBar';
|
||||
import { formatLongNumber } from 'lib/format';
|
||||
import { Flexbox } from 'react-basics';
|
||||
|
||||
export function EventsMetricsBar({ websiteId }: { websiteId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { data, isLoading, isFetched, error } = useWebsiteSessionStats(websiteId);
|
||||
|
||||
return (
|
||||
<Flexbox direction="row" justifyContent="space-between" style={{ minHeight: 120 }}>
|
||||
<MetricsBar isLoading={isLoading} isFetched={isFetched} error={error}>
|
||||
<MetricCard
|
||||
value={data?.visitors?.value}
|
||||
label={formatMessage(labels.visitors)}
|
||||
formatValue={formatLongNumber}
|
||||
/>
|
||||
<MetricCard
|
||||
value={data?.visits?.value}
|
||||
label={formatMessage(labels.visits)}
|
||||
formatValue={formatLongNumber}
|
||||
/>
|
||||
<MetricCard
|
||||
value={data?.pageviews?.value}
|
||||
label={formatMessage(labels.views)}
|
||||
formatValue={formatLongNumber}
|
||||
/>
|
||||
<MetricCard
|
||||
value={data?.events?.value}
|
||||
label={formatMessage(labels.events)}
|
||||
formatValue={formatLongNumber}
|
||||
/>
|
||||
</MetricsBar>
|
||||
<WebsiteDateFilter websiteId={websiteId} />
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventsMetricsBar;
|
||||
44
src/app/(main)/websites/[websiteId]/events/EventsPage.tsx
Normal file
44
src/app/(main)/websites/[websiteId]/events/EventsPage.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
'use client';
|
||||
import WebsiteHeader from '../WebsiteHeader';
|
||||
import EventsDataTable from './EventsDataTable';
|
||||
import EventsMetricsBar from './EventsMetricsBar';
|
||||
import EventsChart from 'components/metrics/EventsChart';
|
||||
import { GridRow } from 'components/layout/Grid';
|
||||
import MetricsTable from 'components/metrics/MetricsTable';
|
||||
import { useMessages } from 'components/hooks';
|
||||
import { Item, Tabs } from 'react-basics';
|
||||
import { useState } from 'react';
|
||||
import EventProperties from './EventProperties';
|
||||
|
||||
export default function EventsPage({ websiteId }) {
|
||||
const [tab, setTab] = useState('activity');
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<>
|
||||
<WebsiteHeader websiteId={websiteId} />
|
||||
<EventsMetricsBar websiteId={websiteId} />
|
||||
<GridRow columns="two-one">
|
||||
<EventsChart websiteId={websiteId} />
|
||||
<MetricsTable
|
||||
websiteId={websiteId}
|
||||
type="event"
|
||||
title={formatMessage(labels.events)}
|
||||
metric={formatMessage(labels.actions)}
|
||||
/>
|
||||
</GridRow>
|
||||
<div>
|
||||
<Tabs
|
||||
selectedKey={tab}
|
||||
onSelect={(value: any) => setTab(value)}
|
||||
style={{ marginBottom: 30 }}
|
||||
>
|
||||
<Item key="activity">{formatMessage(labels.activity)}</Item>
|
||||
<Item key="properties">{formatMessage(labels.properties)}</Item>
|
||||
</Tabs>
|
||||
{tab === 'activity' && <EventsDataTable websiteId={websiteId} />}
|
||||
{tab === 'properties' && <EventProperties websiteId={websiteId} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
src/app/(main)/websites/[websiteId]/events/EventsTable.tsx
Normal file
44
src/app/(main)/websites/[websiteId]/events/EventsTable.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { GridTable, GridColumn, Icon } from 'react-basics';
|
||||
import { useMessages, useTeamUrl, useTimezone } from 'components/hooks';
|
||||
import Empty from 'components/common/Empty';
|
||||
import Avatar from 'components/common/Avatar';
|
||||
import Link from 'next/link';
|
||||
import Icons from 'components/icons';
|
||||
|
||||
export function EventsTable({ data = [] }) {
|
||||
const { formatTimezoneDate } = useTimezone();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { renderTeamUrl } = useTeamUrl();
|
||||
|
||||
if (data.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GridTable data={data}>
|
||||
<GridColumn name="session" label={formatMessage(labels.session)} width={'100px'}>
|
||||
{row => (
|
||||
<Link href={renderTeamUrl(`/websites/${row.websiteId}/sessions/${row.sessionId}`)}>
|
||||
<Avatar seed={row.sessionId} size={64} />
|
||||
</Link>
|
||||
)}
|
||||
</GridColumn>
|
||||
<GridColumn name="event" label={formatMessage(labels.event)}>
|
||||
{row => {
|
||||
return (
|
||||
<>
|
||||
<Icon>{row.eventName ? <Icons.Bolt /> : <Icons.Eye />}</Icon>
|
||||
{formatMessage(row.eventName ? labels.triggeredEvent : labels.viewedPage)}
|
||||
<strong>{row.eventName || row.urlPath}</strong>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</GridColumn>
|
||||
<GridColumn name="created" label={formatMessage(labels.created)} width={'300px'}>
|
||||
{row => formatTimezoneDate(row.createdAt, 'PPPpp')}
|
||||
</GridColumn>
|
||||
</GridTable>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventsTable;
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { Metadata } from 'next';
|
||||
import EventDataPage from './EventDataPage';
|
||||
import EventsPage from './EventsPage';
|
||||
|
||||
export default async function ({ params: { websiteId } }) {
|
||||
return <EventDataPage websiteId={websiteId} />;
|
||||
return <EventsPage websiteId={websiteId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
13
src/app/(main)/websites/[websiteId]/layout.tsx
Normal file
13
src/app/(main)/websites/[websiteId]/layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Metadata } from 'next';
|
||||
import WebsiteProvider from './WebsiteProvider';
|
||||
|
||||
export default function ({ children, params: { websiteId } }) {
|
||||
return <WebsiteProvider websiteId={websiteId}>{children}</WebsiteProvider>;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: '%s | Umami',
|
||||
default: 'Websites | Umami',
|
||||
},
|
||||
};
|
||||
|
|
@ -3,19 +3,17 @@ import ListTable from 'components/metrics/ListTable';
|
|||
import { useLocale, useCountryNames, useMessages } from 'components/hooks';
|
||||
import classNames from 'classnames';
|
||||
import styles from './RealtimeCountries.module.css';
|
||||
import TypeIcon from 'components/common/TypeIcon';
|
||||
|
||||
export function RealtimeCountries({ data }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { locale } = useLocale();
|
||||
const countryNames = useCountryNames(locale);
|
||||
const { countryNames } = useCountryNames(locale);
|
||||
|
||||
const renderCountryName = useCallback(
|
||||
({ x: code }) => (
|
||||
<span className={classNames(locale, styles.row)}>
|
||||
<img
|
||||
src={`${process.env.basePath || ''}/images/flags/${code?.toLowerCase() || 'xx'}.png`}
|
||||
alt={code}
|
||||
/>
|
||||
<TypeIcon type="country" value={code?.toLowerCase()} />
|
||||
{countryNames[code]}
|
||||
</span>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
import { useContext, useMemo, useState } from 'react';
|
||||
import { StatusLight, Icon, Text, SearchField } from 'react-basics';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import { format } from 'date-fns';
|
||||
import thenby from 'thenby';
|
||||
import { safeDecodeURI } from 'next-basics';
|
||||
import FilterButtons from 'components/common/FilterButtons';
|
||||
import Empty from 'components/common/Empty';
|
||||
import { useLocale, useCountryNames, useMessages } from 'components/hooks';
|
||||
import Icons from 'components/icons';
|
||||
import useFormat from 'components//hooks/useFormat';
|
||||
import Empty from 'components/common/Empty';
|
||||
import FilterButtons from 'components/common/FilterButtons';
|
||||
import { useCountryNames, useLocale, useMessages, useTimezone } from 'components/hooks';
|
||||
import Icons from 'components/icons';
|
||||
import { BROWSERS } from 'lib/constants';
|
||||
import { stringToColor } from 'lib/format';
|
||||
import { RealtimeData } from 'lib/types';
|
||||
import { safeDecodeURI } from 'next-basics';
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
import { Icon, SearchField, StatusLight, Text } from 'react-basics';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import { WebsiteContext } from '../WebsiteProvider';
|
||||
import styles from './RealtimeLog.module.css';
|
||||
|
||||
|
|
@ -32,7 +30,8 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
|
|||
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
|
||||
const { formatValue } = useFormat();
|
||||
const { locale } = useLocale();
|
||||
const countryNames = useCountryNames(locale);
|
||||
const { formatTimezoneDate } = useTimezone();
|
||||
const { countryNames } = useCountryNames(locale);
|
||||
const [filter, setFilter] = useState(TYPE_ALL);
|
||||
|
||||
const buttons = [
|
||||
|
|
@ -54,7 +53,7 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
|
|||
},
|
||||
];
|
||||
|
||||
const getTime = ({ timestamp }) => format(timestamp * 1000, 'h:mm:ss');
|
||||
const getTime = ({ createdAt, firstAt }) => formatTimezoneDate(firstAt || createdAt, 'h:mm:ss');
|
||||
|
||||
const getColor = ({ id, sessionId }) => stringToColor(sessionId || id);
|
||||
|
||||
|
|
@ -141,12 +140,7 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
|
|||
return [];
|
||||
}
|
||||
|
||||
const { events, visitors } = data;
|
||||
|
||||
let logs = [
|
||||
...events.map(e => ({ __type: e.eventName ? TYPE_EVENT : TYPE_PAGEVIEW, ...e })),
|
||||
...visitors.map(v => ({ __type: TYPE_SESSION, ...v })),
|
||||
].sort(thenby.firstBy('timestamp', -1));
|
||||
let logs = data.events;
|
||||
|
||||
if (search) {
|
||||
logs = logs.filter(({ eventName, urlPath, browser, os, country, device }) => {
|
||||
|
|
@ -178,7 +172,7 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
|
|||
<SearchField className={styles.search} value={search} onSearch={setSearch} />
|
||||
<FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />
|
||||
</div>
|
||||
<div className={styles.header}>{formatMessage(labels.activityLog)}</div>
|
||||
<div className={styles.header}>{formatMessage(labels.activity)}</div>
|
||||
<div className={styles.body}>
|
||||
{logs?.length === 0 && <Empty />}
|
||||
{logs?.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import RealtimeHeader from './RealtimeHeader';
|
|||
import RealtimeUrls from './RealtimeUrls';
|
||||
import RealtimeCountries from './RealtimeCountries';
|
||||
import WebsiteHeader from '../WebsiteHeader';
|
||||
import WebsiteProvider from '../WebsiteProvider';
|
||||
import { percentFilter } from 'lib/filters';
|
||||
|
||||
export function WebsiteRealtimePage({ websiteId }) {
|
||||
|
|
@ -27,7 +26,7 @@ export function WebsiteRealtimePage({ websiteId }) {
|
|||
);
|
||||
|
||||
return (
|
||||
<WebsiteProvider websiteId={websiteId}>
|
||||
<>
|
||||
<WebsiteHeader websiteId={websiteId} />
|
||||
<RealtimeHeader data={data} />
|
||||
<RealtimeChart data={data} unit="minute" />
|
||||
|
|
@ -41,7 +40,7 @@ export function WebsiteRealtimePage({ websiteId }) {
|
|||
<WorldMap data={countries} />
|
||||
</GridRow>
|
||||
</Grid>
|
||||
</WebsiteProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
||||
gap: 60px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.table {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
cursor: pointer;
|
||||
color: var(--primary400);
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.chart {
|
||||
min-height: 620px;
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { GridColumn, GridTable } from 'react-basics';
|
||||
import { useSessionDataProperties, useSessionDataValues, useMessages } from 'components/hooks';
|
||||
import { LoadingPanel } from 'components/common/LoadingPanel';
|
||||
import PieChart from 'components/charts/PieChart';
|
||||
import { useState } from 'react';
|
||||
import { CHART_COLORS } from 'lib/constants';
|
||||
import styles from './SessionProperties.module.css';
|
||||
|
||||
export function SessionProperties({ websiteId }: { websiteId: string }) {
|
||||
const [propertyName, setPropertyName] = useState('');
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { data, isLoading, isFetched, error } = useSessionDataProperties(websiteId);
|
||||
const { data: values } = useSessionDataValues(websiteId, propertyName);
|
||||
const chartData =
|
||||
propertyName && values
|
||||
? {
|
||||
labels: values.map(({ value }) => value),
|
||||
datasets: [
|
||||
{
|
||||
data: values.map(({ total }) => total),
|
||||
backgroundColor: CHART_COLORS,
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<LoadingPanel isLoading={isLoading} isFetched={isFetched} data={data} error={error}>
|
||||
<div className={styles.container}>
|
||||
<GridTable data={data} cardMode={false} className={styles.table}>
|
||||
<GridColumn name="propertyName" label={formatMessage(labels.property)}>
|
||||
{row => (
|
||||
<div className={styles.link} onClick={() => setPropertyName(row.propertyName)}>
|
||||
{row.propertyName}
|
||||
</div>
|
||||
)}
|
||||
</GridColumn>
|
||||
<GridColumn name="total" label={formatMessage(labels.count)} alignment="end" />
|
||||
</GridTable>
|
||||
{propertyName && (
|
||||
<div className={styles.chart}>
|
||||
<div className={styles.title}>{propertyName}</div>
|
||||
<PieChart key={propertyName} type="doughnut" data={chartData} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionProperties;
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { useWebsiteSessions } from 'components/hooks';
|
||||
import SessionsTable from './SessionsTable';
|
||||
import DataTable from 'components/common/DataTable';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function SessionsDataTable({
|
||||
websiteId,
|
||||
children,
|
||||
}: {
|
||||
websiteId?: string;
|
||||
teamId?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const queryResult = useWebsiteSessions(websiteId);
|
||||
|
||||
return (
|
||||
<DataTable queryResult={queryResult} allowSearch={false} renderEmpty={() => children}>
|
||||
{({ data }) => <SessionsTable data={data} showDomain={!websiteId} />}
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { useMessages } from 'components/hooks';
|
||||
import useWebsiteSessionStats from 'components/hooks/queries/useWebsiteSessionStats';
|
||||
import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
|
||||
import MetricCard from 'components/metrics/MetricCard';
|
||||
import MetricsBar from 'components/metrics/MetricsBar';
|
||||
import { formatLongNumber } from 'lib/format';
|
||||
import { Flexbox } from 'react-basics';
|
||||
|
||||
export function SessionsMetricsBar({ websiteId }: { websiteId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { data, isLoading, isFetched, error } = useWebsiteSessionStats(websiteId);
|
||||
|
||||
return (
|
||||
<Flexbox direction="row" justifyContent="space-between" style={{ minHeight: 120 }}>
|
||||
<MetricsBar isLoading={isLoading} isFetched={isFetched} error={error}>
|
||||
<MetricCard
|
||||
value={data?.visitors?.value}
|
||||
label={formatMessage(labels.visitors)}
|
||||
formatValue={formatLongNumber}
|
||||
/>
|
||||
<MetricCard
|
||||
value={data?.visits?.value}
|
||||
label={formatMessage(labels.visits)}
|
||||
formatValue={formatLongNumber}
|
||||
/>
|
||||
<MetricCard
|
||||
value={data?.pageviews?.value}
|
||||
label={formatMessage(labels.views)}
|
||||
formatValue={formatLongNumber}
|
||||
/>
|
||||
<MetricCard
|
||||
value={data?.countries?.value}
|
||||
label={formatMessage(labels.countries)}
|
||||
formatValue={formatLongNumber}
|
||||
/>
|
||||
</MetricsBar>
|
||||
<WebsiteDateFilter websiteId={websiteId} />
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionsMetricsBar;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
'use client';
|
||||
import WebsiteHeader from '../WebsiteHeader';
|
||||
import SessionsDataTable from './SessionsDataTable';
|
||||
import SessionsMetricsBar from './SessionsMetricsBar';
|
||||
import SessionProperties from './SessionProperties';
|
||||
import WorldMap from 'components/metrics/WorldMap';
|
||||
import { GridRow } from 'components/layout/Grid';
|
||||
import { Item, Tabs } from 'react-basics';
|
||||
import { useState } from 'react';
|
||||
import { useMessages } from 'components/hooks';
|
||||
import SessionsWeekly from './SessionsWeekly';
|
||||
|
||||
export function SessionsPage({ websiteId }) {
|
||||
const [tab, setTab] = useState('activity');
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<>
|
||||
<WebsiteHeader websiteId={websiteId} />
|
||||
<SessionsMetricsBar websiteId={websiteId} />
|
||||
<GridRow columns="two-one">
|
||||
<WorldMap websiteId={websiteId} />
|
||||
<SessionsWeekly websiteId={websiteId} />
|
||||
</GridRow>
|
||||
<Tabs selectedKey={tab} onSelect={(value: any) => setTab(value)} style={{ marginBottom: 30 }}>
|
||||
<Item key="activity">{formatMessage(labels.activity)}</Item>
|
||||
<Item key="properties">{formatMessage(labels.properties)}</Item>
|
||||
</Tabs>
|
||||
{tab === 'activity' && <SessionsDataTable websiteId={websiteId} />}
|
||||
{tab === 'properties' && <SessionProperties websiteId={websiteId} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionsPage;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
.link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import Link from 'next/link';
|
||||
import { GridColumn, GridTable } from 'react-basics';
|
||||
import { useFormat, useMessages, useTimezone } from 'components/hooks';
|
||||
import Avatar from 'components/common/Avatar';
|
||||
import styles from './SessionsTable.module.css';
|
||||
import TypeIcon from 'components/common/TypeIcon';
|
||||
|
||||
export function SessionsTable({ data = [] }: { data: any[]; showDomain?: boolean }) {
|
||||
const { formatTimezoneDate } = useTimezone();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { formatValue } = useFormat();
|
||||
|
||||
return (
|
||||
<GridTable data={data}>
|
||||
<GridColumn name="id" label={formatMessage(labels.session)} width="100px">
|
||||
{row => (
|
||||
<Link href={`sessions/${row.id}`} className={styles.link}>
|
||||
<Avatar key={row.id} seed={row.id} size={64} />
|
||||
</Link>
|
||||
)}
|
||||
</GridColumn>
|
||||
<GridColumn name="visits" label={formatMessage(labels.visits)} width="100px" />
|
||||
<GridColumn name="views" label={formatMessage(labels.views)} width="100px" />
|
||||
<GridColumn name="country" label={formatMessage(labels.country)}>
|
||||
{row => (
|
||||
<TypeIcon type="country" value={row.country}>
|
||||
{formatValue(row.country, 'country')}
|
||||
</TypeIcon>
|
||||
)}
|
||||
</GridColumn>
|
||||
<GridColumn name="city" label={formatMessage(labels.city)} />
|
||||
<GridColumn name="browser" label={formatMessage(labels.browser)}>
|
||||
{row => (
|
||||
<TypeIcon type="browser" value={row.browser}>
|
||||
{formatValue(row.browser, 'browser')}
|
||||
</TypeIcon>
|
||||
)}
|
||||
</GridColumn>
|
||||
<GridColumn name="os" label={formatMessage(labels.os)}>
|
||||
{row => (
|
||||
<TypeIcon type="os" value={row.os}>
|
||||
{formatValue(row.os, 'os')}
|
||||
</TypeIcon>
|
||||
)}
|
||||
</GridColumn>
|
||||
<GridColumn name="device" label={formatMessage(labels.device)}>
|
||||
{row => (
|
||||
<TypeIcon type="device" value={row.device}>
|
||||
{formatValue(row.device, 'device')}
|
||||
</TypeIcon>
|
||||
)}
|
||||
</GridColumn>
|
||||
<GridColumn name="lastAt" label={formatMessage(labels.lastSeen)}>
|
||||
{row => formatTimezoneDate(row.createdAt, 'PPPpp')}
|
||||
</GridColumn>
|
||||
</GridTable>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionsTable;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
.week {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.day {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 1px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
background-color: var(--base75);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin: auto;
|
||||
border-radius: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.hour {
|
||||
font-weight: 700;
|
||||
color: var(--font-color300);
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.block {
|
||||
background-color: var(--primary400);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { format, startOfDay, addHours } from 'date-fns';
|
||||
import { useLocale, useMessages, useWebsiteSessionsWeekly } from 'components/hooks';
|
||||
import { LoadingPanel } from 'components/common/LoadingPanel';
|
||||
import { getDayOfWeekAsDate } from 'lib/date';
|
||||
import styles from './SessionsWeekly.module.css';
|
||||
import classNames from 'classnames';
|
||||
import { TooltipPopup } from 'react-basics';
|
||||
|
||||
export function SessionsWeekly({ websiteId }: { websiteId: string }) {
|
||||
const { data, ...props } = useWebsiteSessionsWeekly(websiteId);
|
||||
const { dateLocale } = useLocale();
|
||||
const { labels, formatMessage } = useMessages();
|
||||
|
||||
const [, max] = 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 {...(props as any)} data={data}>
|
||||
<div key={data} className={styles.week}>
|
||||
<div className={styles.day}>
|
||||
<div className={styles.header}> </div>
|
||||
{Array(24)
|
||||
.fill(null)
|
||||
.map((_, i) => {
|
||||
const label = format(addHours(startOfDay(new Date()), i), 'haaa');
|
||||
return (
|
||||
<div key={i} className={styles.hour}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{data?.map((day: number[], index: number) => {
|
||||
return (
|
||||
<div key={index} className={styles.day}>
|
||||
<div className={styles.header}>
|
||||
{format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })}
|
||||
</div>
|
||||
{day?.map((hour: number) => {
|
||||
const pct = hour / max;
|
||||
return (
|
||||
<div key={hour} className={classNames(styles.cell)}>
|
||||
{hour > 0 && (
|
||||
<TooltipPopup
|
||||
label={`${formatMessage(labels.visitors)}: ${hour}`}
|
||||
position="right"
|
||||
>
|
||||
<div
|
||||
className={styles.block}
|
||||
style={{ opacity: pct, transform: `scale(${pct})` }}
|
||||
/>
|
||||
</TooltipPopup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionsWeekly;
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: var(--font-color200);
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.value {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { isSameDay } from 'date-fns';
|
||||
import { Loading, Icon, StatusLight } from 'react-basics';
|
||||
import Icons from 'components/icons';
|
||||
import { useSessionActivity, useTimezone } from 'components/hooks';
|
||||
import styles from './SessionActivity.module.css';
|
||||
|
||||
export function SessionActivity({
|
||||
websiteId,
|
||||
sessionId,
|
||||
startDate,
|
||||
endDate,
|
||||
}: {
|
||||
websiteId: string;
|
||||
sessionId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}) {
|
||||
const { formatTimezoneDate } = useTimezone();
|
||||
const { data, isLoading } = useSessionActivity(websiteId, sessionId, startDate, endDate);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading position="page" />;
|
||||
}
|
||||
|
||||
let lastDay = null;
|
||||
|
||||
return (
|
||||
<div className={styles.timeline}>
|
||||
{data.map(({ eventId, createdAt, urlPath, eventName, visitId }) => {
|
||||
const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
|
||||
lastDay = createdAt;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showHeader && (
|
||||
<div className={styles.header}>{formatTimezoneDate(createdAt, 'EEEE, PPP')}</div>
|
||||
)}
|
||||
<div key={eventId} className={styles.row}>
|
||||
<div className={styles.time}>
|
||||
<StatusLight color={`#${visitId?.substring(0, 6)}`}>
|
||||
{formatTimezoneDate(createdAt, 'h:mm:ss aaa')}
|
||||
</StatusLight>
|
||||
</div>
|
||||
<Icon>{eventName ? <Icons.Bolt /> : <Icons.Eye />}</Icon>
|
||||
<div className={styles.value}>{eventName || urlPath}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
.data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--font-color300);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.type {
|
||||
font-size: 11px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--base400);
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--font-color200);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { TextOverflow } from 'react-basics';
|
||||
import { useMessages, useSessionData } from 'components/hooks';
|
||||
import Empty from 'components/common/Empty';
|
||||
import { DATA_TYPES } from 'lib/constants';
|
||||
import styles from './SessionData.module.css';
|
||||
import { LoadingPanel } from 'components/common/LoadingPanel';
|
||||
|
||||
export function SessionData({ websiteId, sessionId }: { websiteId: string; sessionId: string }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { data, ...query } = useSessionData(websiteId, sessionId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.header}>{formatMessage(labels.properties)}</div>
|
||||
<LoadingPanel className={styles.data} {...query} data={data}>
|
||||
{!data?.length && <Empty className={styles.empty} />}
|
||||
{data?.map(({ dataKey, dataType, stringValue }) => {
|
||||
return (
|
||||
<div key={dataKey}>
|
||||
<div className={styles.label}>
|
||||
<div className={styles.name}>
|
||||
<TextOverflow>{dataKey}</TextOverflow>
|
||||
</div>
|
||||
<div className={styles.type}>{DATA_TYPES[dataType]}</div>
|
||||
</div>
|
||||
<div className={styles.value}>{stringValue}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</LoadingPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
.page {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr max-content;
|
||||
margin-bottom: 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 20px;
|
||||
width: 300px;
|
||||
padding-right: 20px;
|
||||
border-right: 1px solid var(--base300);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
padding: 0 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.data {
|
||||
width: 300px;
|
||||
border-left: 1px solid var(--base300);
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
transition: width 200ms ease-in-out;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 992px) {
|
||||
.page {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.data {
|
||||
border: 0;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
'use client';
|
||||
import Avatar from 'components/common/Avatar';
|
||||
import { LoadingPanel } from 'components/common/LoadingPanel';
|
||||
import { useWebsiteSession } from 'components/hooks';
|
||||
import WebsiteHeader from '../../WebsiteHeader';
|
||||
import { SessionActivity } from './SessionActivity';
|
||||
import { SessionData } from './SessionData';
|
||||
import styles from './SessionDetailsPage.module.css';
|
||||
import SessionInfo from './SessionInfo';
|
||||
import { SessionStats } from './SessionStats';
|
||||
|
||||
export default function SessionDetailsPage({
|
||||
websiteId,
|
||||
sessionId,
|
||||
}: {
|
||||
websiteId: string;
|
||||
sessionId: string;
|
||||
}) {
|
||||
const { data, ...query } = useWebsiteSession(websiteId, sessionId);
|
||||
|
||||
return (
|
||||
<LoadingPanel {...query} loadingIcon="spinner" data={data}>
|
||||
<WebsiteHeader websiteId={websiteId} />
|
||||
<div className={styles.page}>
|
||||
<div className={styles.sidebar}>
|
||||
<Avatar seed={data?.id} />
|
||||
<SessionInfo data={data} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<SessionStats data={data} />
|
||||
<SessionActivity
|
||||
websiteId={websiteId}
|
||||
sessionId={sessionId}
|
||||
startDate={data?.firstAt}
|
||||
endDate={data?.lastAt}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.data}>
|
||||
<SessionData websiteId={websiteId} sessionId={sessionId} />
|
||||
</div>
|
||||
</div>
|
||||
</LoadingPanel>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
.info {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.info dl {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info dt {
|
||||
color: var(--font-color200);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.info dd {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin: 5px 0 28px;
|
||||
text-align: left;
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { useFormat, useLocale, useMessages, useRegionNames, useTimezone } from 'components/hooks';
|
||||
import TypeIcon from 'components/common/TypeIcon';
|
||||
import { Icon, CopyIcon } from 'react-basics';
|
||||
import Icons from 'components/icons';
|
||||
import styles from './SessionInfo.module.css';
|
||||
|
||||
export default function SessionInfo({ data }) {
|
||||
const { locale } = useLocale();
|
||||
const { formatTimezoneDate } = useTimezone();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { formatValue } = useFormat();
|
||||
const { getRegionName } = useRegionNames(locale);
|
||||
|
||||
return (
|
||||
<div className={styles.info}>
|
||||
<dl>
|
||||
<dt>ID</dt>
|
||||
<dd>
|
||||
{data?.id} <CopyIcon value={data?.id} />
|
||||
</dd>
|
||||
|
||||
<dt>{formatMessage(labels.lastSeen)}</dt>
|
||||
<dd>{formatTimezoneDate(data?.lastAt, 'EEEE, PPPpp')}</dd>
|
||||
|
||||
<dt>{formatMessage(labels.firstSeen)}</dt>
|
||||
<dd>{formatTimezoneDate(data?.firstAt, 'EEEE, PPPpp')}</dd>
|
||||
|
||||
<dt>{formatMessage(labels.country)}</dt>
|
||||
<dd>
|
||||
<TypeIcon type="country" value={data?.country} />
|
||||
{formatValue(data?.country, 'country')}
|
||||
</dd>
|
||||
|
||||
<dt>{formatMessage(labels.region)}</dt>
|
||||
<dd>
|
||||
<Icon>
|
||||
<Icons.Location />
|
||||
</Icon>
|
||||
{getRegionName(data?.subdivision1)}
|
||||
</dd>
|
||||
|
||||
<dt>{formatMessage(labels.city)}</dt>
|
||||
<dd>
|
||||
<Icon>
|
||||
<Icons.Location />
|
||||
</Icon>
|
||||
{data?.city}
|
||||
</dd>
|
||||
|
||||
<dt>{formatMessage(labels.os)}</dt>
|
||||
<dd>
|
||||
<TypeIcon type="os" value={data?.os?.toLowerCase()?.replaceAll(/\W/g, '-')} />
|
||||
{formatValue(data?.os, 'os')}
|
||||
</dd>
|
||||
|
||||
<dt>{formatMessage(labels.device)}</dt>
|
||||
<dd>
|
||||
<TypeIcon type="device" value={data?.device} />
|
||||
{formatValue(data?.device, 'device')}
|
||||
</dd>
|
||||
|
||||
<dt>{formatMessage(labels.browser)}</dt>
|
||||
<dd>
|
||||
<TypeIcon type="browser" value={data?.browser} />
|
||||
{formatValue(data?.browser, 'browser')}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { useMessages } from 'components/hooks';
|
||||
import MetricCard from 'components/metrics/MetricCard';
|
||||
import MetricsBar from 'components/metrics/MetricsBar';
|
||||
import { formatShortTime } from 'lib/format';
|
||||
|
||||
export function SessionStats({ data }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<MetricsBar isFetched={true}>
|
||||
<MetricCard label={formatMessage(labels.visits)} value={data?.visits} />
|
||||
<MetricCard label={formatMessage(labels.views)} value={data?.views} />
|
||||
<MetricCard label={formatMessage(labels.events)} value={data?.events} />
|
||||
<MetricCard
|
||||
label={formatMessage(labels.visitDuration)}
|
||||
value={data?.totaltime / data?.visits}
|
||||
formatValue={n => `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`}
|
||||
/>
|
||||
</MetricsBar>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import SessionDetailsPage from './SessionDetailsPage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default function WebsitePage({ params: { websiteId, sessionId } }) {
|
||||
return <SessionDetailsPage websiteId={websiteId} sessionId={sessionId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Websites',
|
||||
};
|
||||
10
src/app/(main)/websites/[websiteId]/sessions/page.tsx
Normal file
10
src/app/(main)/websites/[websiteId]/sessions/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import SessionsPage from './SessionsPage';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export default function ({ params: { websiteId } }) {
|
||||
return <SessionsPage websiteId={websiteId} />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sessions',
|
||||
};
|
||||
28
src/app/api/scripts/telemetry/route.ts
Normal file
28
src/app/api/scripts/telemetry/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { CURRENT_VERSION, TELEMETRY_PIXEL } from 'lib/constants';
|
||||
|
||||
export async function GET() {
|
||||
if (
|
||||
process.env.NODE_ENV !== 'production' &&
|
||||
process.env.DISABLE_TELEMETRY &&
|
||||
process.env.PRIVATE_MODE
|
||||
) {
|
||||
const script = `
|
||||
(()=>{const i=document.createElement('img');
|
||||
i.setAttribute('src','${TELEMETRY_PIXEL}?v=${CURRENT_VERSION}');
|
||||
i.setAttribute('style','width:0;height:0;position:absolute;pointer-events:none;');
|
||||
document.body.appendChild(i);})();
|
||||
`;
|
||||
|
||||
return new Response(script.replace(/\s\s+/g, ''), {
|
||||
headers: {
|
||||
'content-type': 'text/javascript',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('/* telemetry disabled */', {
|
||||
headers: {
|
||||
'content-type': 'text/javascript',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -21,7 +21,6 @@ export default function ({ children }) {
|
|||
<meta name="msapplication-TileColor" content="#da532c" />
|
||||
<meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#2f2f2f" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
.container {
|
||||
flex: 1;
|
||||
min-height: calc(100vh - 200px);
|
||||
min-height: calc(100dvh - 200px);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Page from 'components/layout/Page';
|
|||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
import styles from './SharePage.module.css';
|
||||
import { WebsiteProvider } from 'app/(main)/websites/[websiteId]/WebsiteProvider';
|
||||
|
||||
export default function SharePage({ shareId }) {
|
||||
const { shareToken, isLoading } = useShareToken(shareId);
|
||||
|
|
@ -17,7 +18,9 @@ export default function SharePage({ shareId }) {
|
|||
<div className={styles.container}>
|
||||
<Page>
|
||||
<Header />
|
||||
<WebsiteDetailsPage websiteId={shareToken.websiteId} />
|
||||
<WebsiteProvider websiteId={shareToken.websiteId}>
|
||||
<WebsiteDetailsPage websiteId={shareToken.websiteId} />
|
||||
</WebsiteProvider>
|
||||
<Footer />
|
||||
</Page>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue