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>

1
src/assets/lightning.svg Normal file
View file

@ -0,0 +1 @@
<svg xml:space="preserve" viewBox="0 0 682.667 682.667" xmlns="http://www.w3.org/2000/svg"><defs><clipPath clipPathUnits="userSpaceOnUse" id="a"><path d="M0 512h512V0H0Z"/></clipPath></defs><g clip-path="url(#a)" transform="matrix(1.33333 0 0 -1.33333 0 682.667)"><path d="M0 0h137.962L69.319-155.807h140.419L.242-482l55.349 222.794h-155.853z" style="fill:none;stroke:currentColor;stroke-width:30;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" transform="translate(201.262 496.994)"/></g></svg>

After

Width:  |  Height:  |  Size: 551 B

1
src/assets/location.svg Normal file
View file

@ -0,0 +1 @@
<svg height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M32 0A24.032 24.032 0 0 0 8 24c0 17.23 22.36 38.81 23.31 39.72a.99.99 0 0 0 1.38 0C33.64 62.81 56 41.23 56 24A24.032 24.032 0 0 0 32 0zm0 35a11 11 0 1 1 11-11 11.007 11.007 0 0 1-11 11z"/></svg>

After

Width:  |  Height:  |  Size: 288 B

View file

@ -1,4 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 428 389.11">
<circle cx="214.15" cy="181" r="171" fill="none" stroke="white" stroke-miterlimit="10" stroke-width="20"/>
<path d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15Z" fill="white"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 428 389.11"><circle cx="214.15" cy="181" r="171" fill="none" stroke="#fff" stroke-miterlimit="10" stroke-width="20"/><path d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15Z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 404 B

After

Width:  |  Height:  |  Size: 394 B

Before After
Before After

1
src/assets/money.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><path d="M347 302c8.271 0 15 6.639 15 14.8h30c0-19.468-12.541-36.067-30-42.231V242h-30v32.58c-17.459 6.192-30 22.865-30 42.42 0 24.813 20.187 45 45 45 8.271 0 15 6.729 15 15s-6.729 15-15 15-15-6.729-15-15h-30c0 19.555 12.541 36.228 30 42.42v32.38h30v-32.38c17.459-6.192 30-22.865 30-42.42 0-24.813-20.187-45-45-45-8.271 0-15-6.729-15-15s6.729-15 15-15z"/><path d="M347 182c-5.057 0-10.058.242-15 .689V90c0-26.011-18.548-49.61-52.226-66.449C249.4 8.364 209.35 0 167 0 124.564 0 84.193 8.347 53.323 23.502 18.938 40.385 0 64 0 90v272c0 26 18.938 49.616 53.323 66.498C84.193 443.653 124.564 452 167 452c17.009 0 33.647-1.358 49.615-4.004C246.826 486.909 294.035 512 347 512c90.981 0 165-74.019 165-165s-74.019-165-165-165zM66.545 50.432C92.992 37.447 129.606 30 167 30c79.558 0 135 31.621 135 60s-55.442 60-135 60c-37.394 0-74.008-7.447-100.455-20.432C43.32 118.166 30 103.744 30 90s13.32-28.166 36.545-39.568zM30 142.265c6.724 5.137 14.512 9.907 23.323 14.233C84.193 171.653 124.564 180 167 180c42.35 0 82.4-8.364 112.774-23.551 8.359-4.18 15.783-8.776 22.226-13.722v45.51c-29.896 8.485-56.359 25.209-76.778 47.548C206.946 239.908 187.386 242 167 242c-37.394 0-74.008-7.447-100.455-20.432C43.32 210.166 30 195.744 30 182v-39.735zm0 92c6.724 5.137 14.512 9.907 23.323 14.233C84.193 263.653 124.564 272 167 272c11.581 0 22.942-.621 34.021-1.839a163.743 163.743 0 0 0-18.293 61.395c-5.211.286-10.465.444-15.728.444-37.394 0-74.008-7.447-100.455-20.432C43.32 300.166 30 285.744 30 272v-37.735zM167 422c-37.394 0-74.008-7.447-100.455-20.432C43.32 390.166 30 375.744 30 362v-37.736c6.724 5.137 14.512 9.907 23.323 14.233C84.193 353.653 124.564 362 167 362c5.23 0 10.459-.132 15.654-.388a163.726 163.726 0 0 0 16.486 58.557A280.559 280.559 0 0 1 167 422zm180 60c-74.439 0-135-60.561-135-135s60.561-135 135-135 135 60.561 135 135-60.561 135-135 135z"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -12,6 +12,8 @@ export interface BarChartProps extends ChartProps {
renderYLabel?: (label: string, index: number, values: any[]) => string;
XAxisType?: string;
YAxisType?: string;
minDate?: number | string;
maxDate?: number | string;
}
export function BarChart(props: BarChartProps) {
@ -24,6 +26,8 @@ export function BarChart(props: BarChartProps) {
XAxisType = 'time',
YAxisType = 'linear',
stacked = false,
minDate,
maxDate,
} = props;
const options: any = useMemo(() => {
@ -32,6 +36,8 @@ export function BarChart(props: BarChartProps) {
x: {
type: XAxisType,
stacked: true,
min: minDate,
max: maxDate,
time: {
unit,
},

View file

@ -0,0 +1,27 @@
import { Chart, ChartProps } from 'components/charts/Chart';
import { useState } from 'react';
import { StatusLight } from 'react-basics';
import { formatLongNumber } from 'lib/format';
export interface BubbleChartProps extends ChartProps {
type?: 'bubble';
}
export default function BubbleChart(props: BubbleChartProps) {
const [tooltip, setTooltip] = useState(null);
const { type = 'bubble' } = props;
const handleTooltip = ({ tooltip }) => {
const { labelColors, dataPoints } = tooltip;
setTooltip(
tooltip.opacity ? (
<StatusLight color={labelColors?.[0]?.backgroundColor}>
{formatLongNumber(dataPoints?.[0]?.raw)} {dataPoints?.[0]?.label}
</StatusLight>
) : null,
);
};
return <Chart {...props} type={type} tooltip={tooltip} onTooltip={handleTooltip} />;
}

View file

@ -79,18 +79,20 @@ export function Chart({
};
const updateChart = (data: any) => {
if (data.datasets.length === chart.current.data.datasets.length) {
chart.current.data.datasets.forEach((dataset: { data: any }, index: string | number) => {
if (data?.datasets[index]) {
dataset.data = data?.datasets[index]?.data;
if (data.datasets) {
if (data.datasets.length === chart.current.data.datasets.length) {
chart.current.data.datasets.forEach((dataset: { data: any }, index: string | number) => {
if (data?.datasets[index]) {
dataset.data = data?.datasets[index]?.data;
if (chart.current.legend.legendItems[index]) {
chart.current.legend.legendItems[index].text = data?.datasets[index]?.label;
if (chart.current.legend.legendItems[index]) {
chart.current.legend.legendItems[index].text = data?.datasets[index]?.label;
}
}
}
});
} else {
chart.current.data.datasets = data.datasets;
});
} else {
chart.current.data.datasets = data.datasets;
}
}
chart.current.options = options;

View file

@ -9,7 +9,7 @@ export interface PieChartProps extends ChartProps {
export default function PieChart(props: PieChartProps) {
const [tooltip, setTooltip] = useState(null);
const { type } = props;
const { type = 'pie' } = props;
const handleTooltip = ({ tooltip }) => {
const { labelColors, dataPoints } = tooltip;
@ -23,5 +23,5 @@ export default function PieChart(props: PieChartProps) {
);
};
return <Chart {...props} type={type || 'pie'} tooltip={tooltip} onTooltip={handleTooltip} />;
return <Chart {...props} type={type} tooltip={tooltip} onTooltip={handleTooltip} />;
}

View file

@ -1,71 +1,23 @@
import md5 from 'md5';
import { colord, extend } from 'colord';
import harmoniesPlugin from 'colord/plugins/harmonies';
import mixPlugin from 'colord/plugins/mix';
import { useMemo } from 'react';
import { createAvatar } from '@dicebear/core';
import { lorelei } from '@dicebear/collection';
import { getColor, getPastel } from 'lib/colors';
extend([harmoniesPlugin, mixPlugin]);
const lib = lorelei;
const harmonies = [
//'analogous',
//'complementary',
'double-split-complementary',
//'rectangle',
'split-complementary',
'tetradic',
//'triadic',
];
function Avatar({ seed, size = 128, ...props }: { seed: string; size?: number }) {
const backgroundColor = getPastel(getColor(seed), 4);
const color = (value: string, invert: boolean = false) => {
const c = colord(value.startsWith('#') ? value : `#${value}`);
const avatar = useMemo(() => {
return createAvatar(lib, {
...props,
seed,
size,
backgroundColor: [backgroundColor],
}).toDataUri();
}, []);
if (invert && c.isDark()) {
return c.invert();
}
return c;
};
const remix = (hash: string) => {
const a = hash.substring(0, 6);
const b = hash.substring(6, 12);
const c = hash.substring(12, 18);
const d = hash.substring(18, 24);
const e = hash.substring(24, 30);
const f = hash.substring(30, 32);
const base = [b, c, d, e]
.reduce((acc, val) => {
return acc.mix(color(val), 0.05);
}, color(a))
.saturate(0.1)
.toHex();
const harmony = pick(parseInt(f, 16), harmonies);
return color(base, true)
.harmonies(harmony)
.map(c => c.toHex());
};
const pick = (num: number, arr: any[]) => {
return arr[num % arr.length];
};
export function Avatar({ value }: { value: string }) {
const hash = md5(value);
const colors = remix(hash);
return (
<svg viewBox="0 0 100 100">
<defs>
<linearGradient id={`color-${hash}`} gradientTransform="rotate(90)">
<stop offset="0%" stopColor={colors[1]} />
<stop offset="100%" stopColor={colors[2]} />
</linearGradient>
</defs>
<circle cx="50" cy="50" r="50" fill={`url(#color-${hash})`} />
</svg>
);
return <img src={avatar} alt="Avatar" style={{ borderRadius: '100%' }} />;
}
export default Avatar;

View file

@ -1,19 +1,21 @@
import { ReactNode } from 'react';
import classNames from 'classnames';
import { Banner, Loading, SearchField } from 'react-basics';
import { useMessages } from 'components/hooks';
import { Loading, SearchField } from 'react-basics';
import { useMessages, useNavigation } from 'components/hooks';
import Empty from 'components/common/Empty';
import Pager from 'components/common/Pager';
import { FilterQueryResult } from 'lib/types';
import { PagedQueryResult } from 'lib/types';
import styles from './DataTable.module.css';
import { LoadingPanel } from 'components/common/LoadingPanel';
const DEFAULT_SEARCH_DELAY = 600;
export interface DataTableProps {
queryResult: FilterQueryResult<any>;
queryResult: PagedQueryResult<any>;
searchDelay?: number;
allowSearch?: boolean;
allowPaging?: boolean;
renderEmpty?: () => ReactNode;
children: ReactNode | ((data: any) => ReactNode);
}
@ -22,6 +24,7 @@ export function DataTable({
searchDelay = 600,
allowSearch = true,
allowPaging = true,
renderEmpty,
children,
}: DataTableProps) {
const { formatMessage, labels, messages } = useMessages();
@ -29,12 +32,13 @@ export function DataTable({
result,
params,
setParams,
query: { error, isLoading },
query: { error, isLoading, isFetched },
} = queryResult || {};
const { page, pageSize, count, data } = result || {};
const { query } = params || {};
const hasData = Boolean(!isLoading && data?.length);
const noResults = Boolean(!isLoading && query && !hasData);
const noResults = Boolean(query && !hasData);
const { router, renderUrl } = useNavigation();
const handleSearch = (query: string) => {
setParams({ ...params, query, page: params.page ? page : 1 });
@ -42,12 +46,9 @@ export function DataTable({
const handlePageChange = (page: number) => {
setParams({ ...params, query, page });
router.push(renderUrl({ page }));
};
if (error) {
return <Banner variant="error">{formatMessage(messages.error)}</Banner>;
}
return (
<>
{allowSearch && (hasData || query) && (
@ -60,23 +61,27 @@ export function DataTable({
placeholder={formatMessage(labels.search)}
/>
)}
<div
className={classNames(styles.body, { [styles.status]: isLoading || noResults || !hasData })}
>
{hasData ? (typeof children === 'function' ? children(result) : children) : null}
{isLoading && <Loading position="page" />}
{!isLoading && !hasData && !query && <Empty />}
{noResults && <Empty message={formatMessage(messages.noResultsFound)} />}
</div>
{allowPaging && hasData && (
<Pager
className={styles.pager}
page={page}
pageSize={pageSize}
count={count}
onPageChange={handlePageChange}
/>
)}
<LoadingPanel data={data} isLoading={isLoading} isFetched={isFetched} error={error}>
<div
className={classNames(styles.body, {
[styles.status]: isLoading || noResults || !hasData,
})}
>
{hasData ? (typeof children === 'function' ? children(result) : children) : null}
{isLoading && <Loading position="page" />}
{!isLoading && !hasData && !query && (renderEmpty ? renderEmpty() : <Empty />)}
{!isLoading && noResults && <Empty message={formatMessage(messages.noResultsFound)} />}
</div>
{allowPaging && hasData && (
<Pager
className={styles.pager}
page={page}
pageSize={pageSize}
count={count}
onPageChange={handlePageChange}
/>
)}
</LoadingPanel>
</>
);
}

View file

@ -0,0 +1,16 @@
.panel {
display: flex;
flex-direction: column;
position: relative;
flex: 1;
height: 100%;
}
.loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
}

View file

@ -0,0 +1,36 @@
import { ReactNode } from 'react';
import classNames from 'classnames';
import { Loading } from 'react-basics';
import ErrorMessage from 'components/common/ErrorMessage';
import Empty from 'components/common/Empty';
import styles from './LoadingPanel.module.css';
export function LoadingPanel({
data,
error,
isFetched,
isLoading,
loadingIcon = 'dots',
className,
children,
}: {
data?: any;
error?: Error;
isFetched?: boolean;
isLoading?: boolean;
loadingIcon?: 'dots' | 'spinner';
isEmpty?: boolean;
className?: string;
children: ReactNode;
}) {
const isEmpty = !isLoading && isFetched && data && Array.isArray(data) && data.length === 0;
return (
<div className={classNames(styles.panel, className)}>
{isLoading && !isFetched && <Loading className={styles.loading} icon={loadingIcon} />}
{error && <ErrorMessage />}
{!error && isEmpty && <Empty />}
{!error && !isEmpty && data && children}
</div>
);
}

View file

@ -27,6 +27,6 @@
}
.nav {
justify-content: end;
justify-content: flex-end;
}
}

View file

@ -0,0 +1,27 @@
import { ReactNode } from 'react';
export function TypeIcon({
type,
value,
children,
}: {
type: 'browser' | 'country' | 'device' | 'os';
value: string;
children?: ReactNode;
}) {
return (
<>
<img
src={`${process.env.basePath || ''}/images/${type}/${
value?.replaceAll(' ', '-').toLowerCase() || 'unknown'
}.png`}
alt={value}
width={type === 'country' ? undefined : 16}
height={type === 'country' ? undefined : 16}
/>
{children}
</>
);
}
export default TypeIcon;

View file

@ -1,10 +1,20 @@
export * from './queries/useApi';
export * from './queries/useConfig';
export * from './queries/useFilterQuery';
export * from './queries/useEventDataEvents';
export * from './queries/useEventDataProperties';
export * from './queries/useEventDataValues';
export * from './queries/usePagedQuery';
export * from './queries/useLogin';
export * from './queries/useRealtime';
export * from './queries/useReport';
export * from './queries/useReports';
export * from './queries/useSessionActivity';
export * from './queries/useSessionData';
export * from './queries/useSessionDataProperties';
export * from './queries/useSessionDataValues';
export * from './queries/useWebsiteSession';
export * from './queries/useWebsiteSessions';
export * from './queries/useWebsiteSessionsWeekly';
export * from './queries/useShareToken';
export * from './queries/useTeam';
export * from './queries/useTeams';
@ -15,6 +25,7 @@ export * from './queries/useUsers';
export * from './queries/useWebsite';
export * from './queries/useWebsites';
export * from './queries/useWebsiteEvents';
export * from './queries/useWebsiteEventsSeries';
export * from './queries/useWebsiteMetrics';
export * from './queries/useWebsiteValues';
export * from './useCountryNames';
@ -30,6 +41,7 @@ export * from './useLocale';
export * from './useMessages';
export * from './useModified';
export * from './useNavigation';
export * from './useRegionNames';
export * from './useSticky';
export * from './useTeamUrl';
export * from './useTheme';

View file

@ -0,0 +1,20 @@
import useApi from './useApi';
import { UseQueryOptions } from '@tanstack/react-query';
import { useFilterParams } from '../useFilterParams';
export function useEventDataEvents(
websiteId: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const params = useFilterParams(websiteId);
return useQuery({
queryKey: ['websites:event-data:events', { websiteId, ...params }],
queryFn: () => get(`/websites/${websiteId}/event-data/events`, { ...params }),
enabled: !!websiteId,
...options,
});
}
export default useEventDataEvents;

View file

@ -0,0 +1,20 @@
import useApi from './useApi';
import { UseQueryOptions } from '@tanstack/react-query';
import { useFilterParams } from '../useFilterParams';
export function useEventDataProperties(
websiteId: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const params = useFilterParams(websiteId);
return useQuery<any>({
queryKey: ['websites:event-data:properties', { websiteId, ...params }],
queryFn: () => get(`/websites/${websiteId}/event-data/properties`, { ...params }),
enabled: !!websiteId,
...options,
});
}
export default useEventDataProperties;

View file

@ -0,0 +1,23 @@
import useApi from './useApi';
import { UseQueryOptions } from '@tanstack/react-query';
import { useFilterParams } from '../useFilterParams';
export function useEventDataValues(
websiteId: string,
eventName: string,
propertyName: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const params = useFilterParams(websiteId);
return useQuery<any>({
queryKey: ['websites:event-data:values', { websiteId, propertyName, ...params }],
queryFn: () =>
get(`/websites/${websiteId}/event-data/values`, { ...params, eventName, propertyName }),
enabled: !!(websiteId && propertyName),
...options,
});
}
export default useEventDataValues;

View file

@ -1,16 +1,18 @@
import { UseQueryOptions } from '@tanstack/react-query';
import { useState } from 'react';
import { useApi } from './useApi';
import { PageResult, PageParams, FilterQueryResult } from 'lib/types';
import { PageResult, PageParams, PagedQueryResult } from 'lib/types';
import { useNavigation } from '../useNavigation';
export function useFilterQuery<T = any>({
export function usePagedQuery<T = any>({
queryKey,
queryFn,
...options
}: Omit<UseQueryOptions, 'queryFn'> & { queryFn: (params?: object) => any }): FilterQueryResult<T> {
const [params, setParams] = useState<T | PageParams>({
}: Omit<UseQueryOptions, 'queryFn'> & { queryFn: (params?: object) => any }): PagedQueryResult<T> {
const { query: queryParams } = useNavigation();
const [params, setParams] = useState<PageParams>({
query: '',
page: 1,
page: +queryParams.page || 1,
});
const { useQuery } = useApi();
@ -21,11 +23,11 @@ export function useFilterQuery<T = any>({
});
return {
result: data as PageResult<any>,
result: data as PageResult<T>,
query,
params,
setParams,
};
}
export default useFilterQuery;
export default usePagedQuery;

View file

@ -1,13 +1,13 @@
import { useTimezone } from 'components/hooks';
import { REALTIME_INTERVAL } from 'lib/constants';
import { RealtimeData } from 'lib/types';
import { useApi } from './useApi';
import { REALTIME_INTERVAL } from 'lib/constants';
import { useTimezone } from 'components/hooks';
export function useRealtime(websiteId: string) {
const { get, useQuery } = useApi();
const { timezone } = useTimezone();
const { data, isLoading, error } = useQuery<RealtimeData>({
queryKey: ['realtime', websiteId],
queryKey: ['realtime', { websiteId, timezone }],
queryFn: async () => {
return get(`/realtime/${websiteId}`, { timezone });
},

View file

@ -1,11 +1,11 @@
import useApi from './useApi';
import useFilterQuery from './useFilterQuery';
import usePagedQuery from './usePagedQuery';
import useModified from '../useModified';
export function useReports({ websiteId, teamId }: { websiteId?: string; teamId?: string }) {
const { modified } = useModified(`reports`);
const { get, del, useMutation } = useApi();
const queryResult = useFilterQuery({
const queryResult = usePagedQuery({
queryKey: ['reports', { websiteId, teamId, modified }],
queryFn: (params: any) => {
return get('/reports', { websiteId, teamId, ...params });

View file

@ -0,0 +1,21 @@
import { useApi } from './useApi';
export function useSessionActivity(
websiteId: string,
sessionId: string,
startDate: Date,
endDate: Date,
) {
const { get, useQuery } = useApi();
return useQuery({
queryKey: ['session:activity', { websiteId, sessionId, startDate, endDate }],
queryFn: () => {
return get(`/websites/${websiteId}/sessions/${sessionId}/activity`, {
startAt: +new Date(startDate),
endAt: +new Date(endDate),
});
},
enabled: Boolean(websiteId && sessionId && startDate && endDate),
});
}

View file

@ -0,0 +1,12 @@
import { useApi } from './useApi';
export function useSessionData(websiteId: string, sessionId: string) {
const { get, useQuery } = useApi();
return useQuery({
queryKey: ['session:data', { websiteId, sessionId }],
queryFn: () => {
return get(`/websites/${websiteId}/sessions/${sessionId}/properties`, { websiteId });
},
});
}

View file

@ -0,0 +1,20 @@
import useApi from './useApi';
import { UseQueryOptions } from '@tanstack/react-query';
import { useFilterParams } from '../useFilterParams';
export function useSessionDataProperties(
websiteId: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const params = useFilterParams(websiteId);
return useQuery<any>({
queryKey: ['websites:event-data:properties', { websiteId, ...params }],
queryFn: () => get(`/websites/${websiteId}/session-data/properties`, { ...params }),
enabled: !!websiteId,
...options,
});
}
export default useSessionDataProperties;

View file

@ -0,0 +1,21 @@
import useApi from './useApi';
import { UseQueryOptions } from '@tanstack/react-query';
import { useFilterParams } from '../useFilterParams';
export function useSessionDataValues(
websiteId: string,
propertyName: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const params = useFilterParams(websiteId);
return useQuery<any>({
queryKey: ['websites:session-data:values', { websiteId, propertyName, ...params }],
queryFn: () => get(`/websites/${websiteId}/session-data/values`, { ...params, propertyName }),
enabled: !!(websiteId && propertyName),
...options,
});
}
export default useSessionDataValues;

View file

@ -1,12 +1,12 @@
import useApi from './useApi';
import useFilterQuery from './useFilterQuery';
import usePagedQuery from './usePagedQuery';
import useModified from '../useModified';
export function useTeamMembers(teamId: string) {
const { get } = useApi();
const { modified } = useModified(`teams:members`);
return useFilterQuery({
return usePagedQuery({
queryKey: ['teams:members', { teamId, modified }],
queryFn: (params: any) => {
return get(`/teams/${teamId}/users`, params);

View file

@ -1,12 +1,12 @@
import useApi from './useApi';
import useFilterQuery from './useFilterQuery';
import usePagedQuery from './usePagedQuery';
import useModified from '../useModified';
export function useTeamWebsites(teamId: string) {
const { get } = useApi();
const { modified } = useModified(`websites`);
return useFilterQuery({
return usePagedQuery({
queryKey: ['teams:websites', { teamId, modified }],
queryFn: (params: any) => {
return get(`/teams/${teamId}/websites`, params);

View file

@ -1,12 +1,12 @@
import useApi from './useApi';
import useFilterQuery from './useFilterQuery';
import usePagedQuery from './usePagedQuery';
import useModified from '../useModified';
export function useTeams(userId: string) {
const { get } = useApi();
const { modified } = useModified(`teams`);
return useFilterQuery({
return usePagedQuery({
queryKey: ['teams', { userId, modified }],
queryFn: (params: any) => {
return get(`/users/${userId}/teams`, params);

View file

@ -1,12 +1,12 @@
import useApi from './useApi';
import useFilterQuery from './useFilterQuery';
import usePagedQuery from './usePagedQuery';
import useModified from '../useModified';
export function useUsers() {
const { get } = useApi();
const { modified } = useModified(`users`);
return useFilterQuery({
return usePagedQuery({
queryKey: ['users', { modified }],
queryFn: (params: any) => {
return get('/admin/users', {

View file

@ -1,17 +1,19 @@
import useApi from './useApi';
import { useFilterParams } from '../useFilterParams';
import { UseQueryOptions } from '@tanstack/react-query';
import { useFilterParams } from '../useFilterParams';
import { usePagedQuery } from './usePagedQuery';
export function useWebsiteEvents(
websiteId: string,
options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const { get } = useApi();
const params = useFilterParams(websiteId);
return useQuery({
return usePagedQuery({
queryKey: ['websites:events', { websiteId, ...params }],
queryFn: () => get(`/websites/${websiteId}/events`, params),
queryFn: pageParams =>
get(`/websites/${websiteId}/events`, { ...params, ...pageParams, pageSize: 20 }),
enabled: !!websiteId,
...options,
});

Some files were not shown because too many files have changed in this diff Show more