sync umami

This commit is contained in:
Viet-Tien Ngoc 2024-08-26 13:51:45 +07:00
commit cc4b21a070
600 changed files with 10884 additions and 3381 deletions

View file

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

View file

@ -17,5 +17,6 @@
grid-row: 2 / 3;
min-height: 0;
height: calc(100vh - 60px);
height: calc(100dvh - 60px);
overflow-y: auto;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -48,7 +48,7 @@ export function EventDataParameters() {
groups,
};
const handleSubmit = values => {
const handleSubmit = (values: any) => {
runReport(values);
};

View file

@ -34,6 +34,10 @@
background-color: var(--base100);
}
.step:last-child::before {
display: none;
}
.card {
display: grid;
gap: 20px;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,4 @@
'use client';
import { createContext, ReactNode, useEffect } from 'react';
import { useModified, useWebsite } from 'components/hooks';
import { Loading } from 'react-basics';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +0,0 @@
.container a {
color: var(--font-color100);
}
.container a:hover {
color: var(--primary400);
}

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
.link {
display: flex;
align-items: center;
gap: 20px;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

@ -1,4 +1,5 @@
.container {
flex: 1;
min-height: calc(100vh - 200px);
min-height: calc(100dvh - 200px);
}

View file

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