Renamed (app) folder to (main).

This commit is contained in:
Mike Cao 2023-10-03 16:05:17 -07:00
parent 5c15778c9b
commit c990459238
167 changed files with 48 additions and 114 deletions

View file

@ -0,0 +1,41 @@
import { useState } from 'react';
import { Grid, GridRow } from 'components/layout/Grid';
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/common/WorldMap';
import CountriesTable from 'components/metrics/CountriesTable';
import EventsTable from 'components/metrics/EventsTable';
import EventsChart from 'components/metrics/EventsChart';
export default function WebsiteTableView({ websiteId }) {
const [countryData, setCountryData] = useState();
const tableProps = {
websiteId,
limit: 10,
};
return (
<Grid>
<GridRow columns="two">
<PagesTable {...tableProps} />
<ReferrersTable {...tableProps} />
</GridRow>
<GridRow columns="three">
<BrowsersTable {...tableProps} />
<OSTable {...tableProps} />
<DevicesTable {...tableProps} />
</GridRow>
<GridRow columns="two-one">
<WorldMap data={countryData} />
<CountriesTable {...tableProps} onDataLoad={setCountryData} />
</GridRow>
<GridRow columns="one-two">
<EventsTable {...tableProps} />
<EventsChart websiteId={websiteId} />
</GridRow>
</Grid>
);
}

View file

@ -0,0 +1,30 @@
'use client';
import WebsiteList from '../../(main)/settings/websites/Websites';
import { useMessages } from 'components/hooks';
import { useState } from 'react';
import { Item, Tabs } from 'react-basics';
const TABS = {
myWebsites: 'my-websites',
teamWebsites: 'team-websites',
};
export function WebsitesBrowse() {
const { formatMessage, labels } = useMessages();
const [tab, setTab] = useState(TABS.myWebsites);
return (
<>
<Tabs selectedKey={tab} onSelect={setTab} style={{ marginBottom: 30 }}>
<Item key={TABS.myWebsites}>{formatMessage(labels.myWebsites)}</Item>
<Item key={TABS.teamWebsites}>{formatMessage(labels.teamWebsites)}</Item>
</Tabs>
{tab === TABS.myWebsites && <WebsiteList showHeader={false} />}
{tab === TABS.teamWebsites && (
<WebsiteList showHeader={false} showTeam={true} onlyTeams={true} />
)}
</>
);
}
export default WebsitesBrowse;

View file

@ -0,0 +1,51 @@
import { useMemo } from 'react';
import PageviewsChart from 'components/metrics/PageviewsChart';
import { useApi, useDateRange, useTimezone, useNavigation } from 'components/hooks';
import { getDateArray } from 'lib/date';
export function WebsiteChart({ websiteId }) {
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, unit, modified } = dateRange;
const [timezone] = useTimezone();
const {
query: { url, referrer, os, browser, device, country, region, city, title },
} = useNavigation();
const { get, useQuery } = useApi();
const { data, isLoading } = useQuery(
[
'websites:pageviews',
{ websiteId, modified, url, referrer, os, browser, device, country, region, city, title },
],
() =>
get(`/websites/${websiteId}/pageviews`, {
startAt: +startDate,
endAt: +endDate,
unit,
timezone,
url,
referrer,
os,
browser,
device,
country,
region,
city,
title,
}),
);
const chartData = useMemo(() => {
if (data) {
return {
pageviews: getDateArray(data.pageviews, startDate, endDate, unit),
sessions: getDateArray(data.sessions, startDate, endDate, unit),
};
}
return { pageviews: [], sessions: [] };
}, [data, startDate, endDate, unit]);
return <PageviewsChart websiteId={websiteId} data={chartData} unit={unit} loading={isLoading} />;
}
export default WebsiteChart;

View file

@ -0,0 +1,17 @@
.container {
position: relative;
display: flex;
flex-direction: column;
align-self: stretch;
}
.chart {
position: relative;
overflow: hidden;
}
.title {
font-size: var(--font-size-lg);
line-height: 60px;
font-weight: 600;
}

View file

@ -0,0 +1,49 @@
import { Button, Text, Icon } from 'react-basics';
import { useMemo } from 'react';
import { firstBy } from 'thenby';
import Link from 'next/link';
import WebsiteChart from './WebsiteChart';
import useDashboard from 'store/dashboard';
import WebsiteHeader from './WebsiteHeader';
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
import { useMessages, useLocale } from 'components/hooks';
import Icons from 'components/icons';
export default function WebsiteChartList({ websites, showCharts, limit }) {
const { formatMessage, labels } = useMessages();
const { websiteOrder } = useDashboard();
const { dir } = useLocale();
const ordered = useMemo(
() =>
websites
.map(website => ({ ...website, order: websiteOrder.indexOf(website.id) || 0 }))
.sort(firstBy('order')),
[websites, websiteOrder],
);
return (
<div>
{ordered.map(({ id }, index) => {
return index < limit ? (
<div key={id}>
<WebsiteHeader websiteId={id} showLinks={false}>
<Link href={`/websites/${id}`}>
<Button variant="primary">
<Text>{formatMessage(labels.viewDetails)}</Text>
<Icon>
<Icon rotate={dir === 'rtl' ? 180 : 0}>
<Icons.ArrowRight />
</Icon>
</Icon>
</Button>
</Link>
</WebsiteHeader>
<WebsiteMetricsBar websiteId={id} showFilter={false} />
{showCharts && <WebsiteChart websiteId={id} />}
</div>
) : null;
})}
</div>
);
}

View file

@ -0,0 +1,45 @@
'use client';
import { Loading } from 'react-basics';
import { usePathname } from 'next/navigation';
import Page from 'components/layout/Page';
import FilterTags from 'components/metrics/FilterTags';
import useNavigation from 'components/hooks/useNavigation';
import { useWebsite } from 'components/hooks';
import WebsiteChart from './WebsiteChart';
import WebsiteMenuView from './WebsiteMenuView';
import WebsiteHeader from './WebsiteHeader';
import WebsiteMetricsBar from './WebsiteMetricsBar';
import WebsiteTableView from '../WebsiteTableView';
export default function WebsiteDetails({ websiteId }) {
const { data: website, isLoading, error } = useWebsite(websiteId);
const pathname = usePathname();
const showLinks = !pathname.includes('/share/');
const {
query: { view, url, referrer, os, browser, device, country, region, city, title },
} = useNavigation();
if (isLoading || error) {
return <Page isLoading={isLoading} error={error} />;
}
return (
<>
<WebsiteHeader websiteId={websiteId} showLinks={showLinks} />
<FilterTags
websiteId={websiteId}
params={{ url, referrer, os, browser, device, country, region, city, title }}
/>
<WebsiteMetricsBar websiteId={websiteId} sticky={true} />
<WebsiteChart websiteId={websiteId} />
{!website && <Loading icon="dots" style={{ minHeight: 300 }} />}
{website && (
<>
{!view && <WebsiteTableView websiteId={websiteId} />}
{view && <WebsiteMenuView websiteId={websiteId} />}
</>
)}
</>
);
}

View file

@ -0,0 +1,78 @@
'use client';
import classNames from 'classnames';
import { Text, Button, Icon } from 'react-basics';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import Favicon from 'components/common/Favicon';
import ActiveUsers from 'components/metrics/ActiveUsers';
import Icons from 'components/icons';
import { useMessages, useWebsite } from 'components/hooks';
import styles from './WebsiteHeader.module.css';
export function WebsiteHeader({ websiteId, showLinks = true, children }) {
const { formatMessage, labels } = useMessages();
const pathname = usePathname();
const { data: website } = useWebsite(websiteId);
const { name, domain } = website || {};
const links = [
{
label: formatMessage(labels.overview),
icon: <Icons.Overview />,
path: '',
},
{
label: formatMessage(labels.realtime),
icon: <Icons.Clock />,
path: '/realtime',
},
{
label: formatMessage(labels.reports),
icon: <Icons.Reports />,
path: '/reports',
},
{
label: formatMessage(labels.eventData),
icon: <Icons.Nodes />,
path: '/event-data',
},
];
return (
<div className={styles.header}>
<div className={styles.title}>
<Favicon domain={domain} />
<Text>{name}</Text>
<ActiveUsers websiteId={websiteId} />
</div>
<div className={styles.actions}>
{showLinks && (
<div className={styles.links}>
{links.map(({ label, icon, path }) => {
const selected = path
? pathname.endsWith(path)
: pathname.match(/^\/websites\/[\w-]+$/);
return (
<Link key={label} href={`/websites/${websiteId}${path}`} shallow={true}>
<Button
variant="quiet"
className={classNames({
[styles.selected]: selected,
})}
>
<Icon className={styles.icon}>{icon}</Icon>
<Text className={styles.label}>{label}</Text>
</Button>
</Link>
);
})}
</div>
)}
{children}
</div>
</div>
);
}
export default WebsiteHeader;

View file

@ -0,0 +1,59 @@
.header {
display: grid;
grid-template-columns: 1fr max-content;
align-items: center;
}
.title {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
font-size: 24px;
font-weight: 700;
overflow: hidden;
height: 100px;
}
.actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 30px;
min-height: 0;
}
.selected {
font-weight: bold;
}
.links {
display: flex;
flex-direction: row;
align-items: center;
}
@media only screen and (max-width: 768px) {
.header {
grid-template-columns: 1fr;
}
.links {
justify-content: space-evenly;
flex: 1;
border-bottom: 1px solid var(--base300);
padding-bottom: 10px;
margin-bottom: 10px;
}
.label {
display: none;
}
.icon,
.icon svg {
width: 20px;
height: 20px;
}
}

View file

@ -0,0 +1,129 @@
import { Text } from 'react-basics';
import BrowsersTable from 'components/metrics/BrowsersTable';
import CountriesTable from 'components/metrics/CountriesTable';
import RegionsTable from 'components/metrics/RegionsTable';
import CitiesTable from 'components/metrics/CitiesTable';
import DevicesTable from 'components/metrics/DevicesTable';
import LanguagesTable from 'components/metrics/LanguagesTable';
import OSTable from 'components/metrics/OSTable';
import PagesTable from 'components/metrics/PagesTable';
import QueryParametersTable from 'components/metrics/QueryParametersTable';
import ReferrersTable from 'components/metrics/ReferrersTable';
import ScreenTable from 'components/metrics/ScreenTable';
import EventsTable from 'components/metrics/EventsTable';
import SideNav from 'components/layout/SideNav';
import useNavigation from 'components/hooks/useNavigation';
import useMessages from 'components/hooks/useMessages';
import styles from './WebsiteMenuView.module.css';
import LinkButton from 'components/common/LinkButton';
const views = {
url: PagesTable,
title: PagesTable,
referrer: ReferrersTable,
browser: BrowsersTable,
os: OSTable,
device: DevicesTable,
screen: ScreenTable,
country: CountriesTable,
region: RegionsTable,
city: CitiesTable,
language: LanguagesTable,
event: EventsTable,
query: QueryParametersTable,
};
export default function WebsiteMenuView({ websiteId, websiteDomain }) {
const { formatMessage, labels } = useMessages();
const {
makeUrl,
pathname,
query: { view },
} = useNavigation();
const items = [
{
key: 'url',
label: formatMessage(labels.pages),
url: makeUrl({ view: 'url' }),
},
{
key: 'referrer',
label: formatMessage(labels.referrers),
url: makeUrl({ view: 'referrer' }),
},
{
key: 'browser',
label: formatMessage(labels.browsers),
url: makeUrl({ view: 'browser' }),
},
{
key: 'os',
label: formatMessage(labels.os),
url: makeUrl({ view: 'os' }),
},
{
key: 'device',
label: formatMessage(labels.devices),
url: makeUrl({ view: 'device' }),
},
{
key: 'country',
label: formatMessage(labels.countries),
url: makeUrl({ view: 'country' }),
},
{
key: 'region',
label: formatMessage(labels.regions),
url: makeUrl({ view: 'region' }),
},
{
key: 'city',
label: formatMessage(labels.cities),
url: makeUrl({ view: 'city' }),
},
{
key: 'language',
label: formatMessage(labels.languages),
url: makeUrl({ view: 'language' }),
},
{
key: 'screen',
label: formatMessage(labels.screens),
url: makeUrl({ view: 'screen' }),
},
{
key: 'event',
label: formatMessage(labels.events),
url: makeUrl({ view: 'event' }),
},
{
key: 'query',
label: formatMessage(labels.queryParameters),
url: makeUrl({ view: 'query' }),
},
];
const DetailsComponent = views[view] || (() => null);
return (
<div className={styles.layout}>
<div className={styles.menu}>
<LinkButton href={pathname} className={styles.back}>
<Text>{formatMessage(labels.back)}</Text>
</LinkButton>
<SideNav items={items} selectedKey={view} shallow={true} />
</div>
<div className={styles.content}>
<DetailsComponent
websiteId={websiteId}
websiteDomain={websiteDomain}
limit={false}
animate={false}
showFilters={true}
virtualize={true}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,25 @@
.layout {
display: grid;
grid-template-columns: 300px 1fr;
border-top: 1px solid var(--base300);
}
.menu {
display: flex;
flex-direction: column;
position: relative;
padding: 20px 20px 20px 0;
}
.back {
display: inline-flex;
align-items: center;
align-self: center;
margin-bottom: 20px;
}
.content {
min-height: 800px;
padding: 20px 0 20px 20px;
border-left: 1px solid var(--base300);
}

View file

@ -0,0 +1,162 @@
import classNames from 'classnames';
import { useApi, useDateRange, useMessages, useNavigation, useSticky } from 'components/hooks';
import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
import MetricCard from 'components/metrics/MetricCard';
import MetricsBar from 'components/metrics/MetricsBar';
import FilterSelectForm from '../../../(main)/reports/FilterSelectForm';
import PopupForm from '../../../(main)/reports/PopupForm';
import { formatShortTime } from 'lib/format';
import { Button, Icon, Icons, Popup, PopupTrigger } from 'react-basics';
import styles from './WebsiteMetricsBar.module.css';
export function WebsiteMetricsBar({ websiteId, showFilter = true, sticky }) {
const { formatMessage, labels } = useMessages();
const { get, useQuery } = useApi();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, modified } = dateRange;
const { ref, isSticky } = useSticky({ enabled: sticky });
const {
makeUrl,
router,
query: { url, referrer, title, os, browser, device, country, region, city },
} = useNavigation();
const { data, error, isLoading, isFetched } = useQuery(
[
'websites:stats',
{ websiteId, modified, url, referrer, title, os, browser, device, country, region, city },
],
() =>
get(`/websites/${websiteId}/stats`, {
startAt: +startDate,
endAt: +endDate,
url,
referrer,
title,
os,
browser,
device,
country,
region,
city,
}),
);
const fieldOptions = [
{ name: 'url', type: 'string', label: formatMessage(labels.url) },
{ name: 'referrer', type: 'string', label: formatMessage(labels.referrer) },
{ name: 'browser', type: 'string', label: formatMessage(labels.browser) },
{ name: 'os', type: 'string', label: formatMessage(labels.os) },
{ name: 'device', type: 'string', label: formatMessage(labels.device) },
{ name: 'country', type: 'string', label: formatMessage(labels.country) },
{ name: 'region', type: 'string', label: formatMessage(labels.region) },
{ name: 'city', type: 'string', label: formatMessage(labels.city) },
];
const { pageviews, uniques, bounces, totaltime } = data || {};
const num = Math.min(data && uniques.value, data && bounces.value);
const diffs = data && {
pageviews: pageviews.value - pageviews.change,
uniques: uniques.value - uniques.change,
bounces: bounces.value - bounces.change,
totaltime: totaltime.value - totaltime.change,
};
const handleAddFilter = ({ name, value }) => {
router.push(makeUrl({ [name]: value }));
};
const WebsiteFilterButton = () => {
return (
<PopupTrigger>
<Button>
<Icon>
<Icons.Plus />
</Icon>
{formatMessage(labels.filter)}
</Button>
<Popup position="bottom" alignment="start" className={styles.popup}>
{close => {
return (
<PopupForm onClose={close}>
<FilterSelectForm
websiteId={websiteId}
items={fieldOptions}
onSelect={value => {
handleAddFilter(value);
close();
}}
allowFilterSelect={false}
/>
</PopupForm>
);
}}
</Popup>
</PopupTrigger>
);
};
return (
<div
ref={ref}
className={classNames(styles.container, {
[styles.sticky]: sticky,
[styles.isSticky]: isSticky,
})}
>
<MetricsBar isLoading={isLoading} isFetched={isFetched} error={error}>
{pageviews && uniques && (
<>
<MetricCard
label={formatMessage(labels.views)}
value={pageviews.value}
change={pageviews.change}
/>
<MetricCard
label={formatMessage(labels.visitors)}
value={uniques.value}
change={uniques.change}
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.bounceRate)}
value={uniques.value ? (num / uniques.value) * 100 : 0}
change={
uniques.value && uniques.change
? (num / uniques.value) * 100 -
(Math.min(diffs.uniques, diffs.bounces) / diffs.uniques) * 100 || 0
: 0
}
format={n => Number(n).toFixed(0) + '%'}
reverseColors
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.averageVisitTime)}
value={
totaltime.value && pageviews.value
? totaltime.value / (pageviews.value - bounces.value)
: 0
}
change={
totaltime.value && pageviews.value
? (diffs.totaltime / (diffs.pageviews - diffs.bounces) -
totaltime.value / (pageviews.value - bounces.value)) *
-1 || 0
: 0
}
format={n => `${n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`}
/>
</>
)}
</MetricsBar>
<div className={styles.actions}>
{showFilter && <WebsiteFilterButton />}
<WebsiteDateFilter websiteId={websiteId} />
</div>
</div>
);
}
export default WebsiteMetricsBar;

View file

@ -0,0 +1,40 @@
.container {
display: grid;
grid-template-columns: 1fr max-content;
justify-content: space-between;
align-items: center;
background: var(--base50);
z-index: var(--z-index-above);
min-height: 120px;
padding-bottom: 20px;
}
.actions {
display: flex;
align-items: center;
flex-direction: row;
justify-content: flex-end;
gap: 10px;
}
@media only screen and (max-width: 1200px) {
.container {
grid-template-columns: 1fr;
}
.actions {
margin: 20px 0;
}
}
@media only screen and (min-width: 992px) {
.sticky {
position: sticky;
top: -1px;
}
.isSticky {
padding: 10px 0;
border-bottom: 1px solid var(--base300);
}
}

View file

@ -0,0 +1,38 @@
import { useApi, useDateRange } from 'components/hooks';
import MetricCard from 'components/metrics/MetricCard';
import useMessages from 'components/hooks/useMessages';
import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
import MetricsBar from 'components/metrics/MetricsBar';
import styles from './EventDataMetricsBar.module.css';
export function EventDataMetricsBar({ websiteId }) {
const { formatMessage, labels } = useMessages();
const { get, useQuery } = useApi();
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate, modified } = dateRange;
const { data, error, isLoading, isFetched } = useQuery(
['event-data:stats', { websiteId, startDate, endDate, modified }],
() =>
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

@ -0,0 +1,26 @@
.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

@ -0,0 +1,37 @@
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 { makeUrl } = useNavigation();
if (data.length === 0) {
return <Empty />;
}
return (
<GridTable data={data}>
<GridColumn name="eventName" label={formatMessage(labels.event)}>
{row => (
<Link href={makeUrl({ 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

@ -0,0 +1,49 @@
import { GridTable, GridColumn, Button, Icon, Text } from 'react-basics';
import { useMessages, useNavigation } from 'components/hooks';
import Link from 'next/link';
import Icons from 'components/icons';
import PageHeader from 'components/layout/PageHeader';
import Empty from 'components/common/Empty';
import { DATA_TYPES } from 'lib/constants';
export function EventDataValueTable({ data = [], event }) {
const { formatMessage, labels } = useMessages();
const { makeUrl } = useNavigation();
const Title = () => {
return (
<>
<Link href={makeUrl({ event: undefined })}>
<Button>
<Icon rotate={180}>
<Icons.ArrowRight />
</Icon>
<Text>{formatMessage(labels.back)}</Text>
</Button>
</Link>
<Text>{event}</Text>
</>
);
};
return (
<>
<PageHeader title={<Title />} />
{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

@ -0,0 +1,42 @@
'use client';
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, event) {
const [dateRange] = useDateRange(websiteId);
const { startDate, endDate } = dateRange;
const { get, useQuery } = useApi();
const { data, error, isLoading } = useQuery(
['event-data:events', { websiteId, startDate, endDate, event }],
() =>
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,7 @@
.container a {
color: var(--font-color100);
}
.container a:hover {
color: var(--primary400);
}

View file

@ -0,0 +1,15 @@
import WebsiteHeader from '../WebsiteHeader';
import WebsiteEventData from './WebsiteEventData';
export default function WebsiteEventDataPage({ params: { id } }) {
if (!id) {
return null;
}
return (
<>
<WebsiteHeader websiteId={id} />
<WebsiteEventData websiteId={id} />
</>
);
}

View file

@ -0,0 +1,5 @@
import WebsiteDetails from './WebsiteDetails';
export default function WebsiteReportsPage({ params: { id } }) {
return <WebsiteDetails websiteId={id} />;
}

View file

@ -0,0 +1,116 @@
'use client';
import { useMemo, useState, useEffect } from 'react';
import { subMinutes, startOfMinute } from 'date-fns';
import firstBy from 'thenby';
import { Grid, GridRow } from 'components/layout/Grid';
import Page from 'components/layout/Page';
import RealtimeChart from 'components/metrics/RealtimeChart';
import WorldMap from 'components/common/WorldMap';
import RealtimeLog from './RealtimeLog';
import RealtimeHeader from './RealtimeHeader';
import RealtimeUrls from './RealtimeUrls';
import RealtimeCountries from './RealtimeCountries';
import WebsiteHeader from '../WebsiteHeader';
import useApi from 'components/hooks/useApi';
import { percentFilter } from 'lib/filters';
import { REALTIME_RANGE, REALTIME_INTERVAL } from 'lib/constants';
import { useWebsite } from 'components/hooks';
import styles from './Realtime.module.css';
function mergeData(state = [], data = [], time) {
const ids = state.map(({ __id }) => __id);
return state
.concat(data.filter(({ __id }) => !ids.includes(__id)))
.filter(({ timestamp }) => timestamp >= time);
}
export function Realtime({ websiteId }) {
const [currentData, setCurrentData] = useState();
const { get, useQuery } = useApi();
const { data: website } = useWebsite(websiteId);
const { data, isLoading, error } = useQuery(
['realtime', websiteId],
() => get(`/realtime/${websiteId}`, { startAt: currentData?.timestamp || 0 }),
{
enabled: !!(websiteId && website),
refetchInterval: REALTIME_INTERVAL,
cache: false,
},
);
useEffect(() => {
if (data) {
const date = subMinutes(startOfMinute(new Date()), REALTIME_RANGE);
const time = date.getTime();
setCurrentData(state => ({
pageviews: mergeData(state?.pageviews, data.pageviews, time),
sessions: mergeData(state?.sessions, data.sessions, time),
events: mergeData(state?.events, data.events, time),
timestamp: data.timestamp,
}));
}
}, [data]);
const realtimeData = useMemo(() => {
if (!currentData) {
return { pageviews: [], sessions: [], events: [], countries: [], visitors: [] };
}
currentData.countries = percentFilter(
currentData.sessions
.reduce((arr, data) => {
if (!arr.find(({ id }) => id === data.id)) {
return arr.concat(data);
}
return arr;
}, [])
.reduce((arr, { country }) => {
if (country) {
const row = arr.find(({ x }) => x === country);
if (!row) {
arr.push({ x: country, y: 1 });
} else {
row.y += 1;
}
}
return arr;
}, [])
.sort(firstBy('y', -1)),
);
currentData.visitors = currentData.sessions.reduce((arr, val) => {
if (!arr.find(({ id }) => id === val.id)) {
return arr.concat(val);
}
return arr;
}, []);
return currentData;
}, [currentData]);
if (isLoading || error) {
return <Page loading={isLoading} error={error} />;
}
return (
<>
<WebsiteHeader websiteId={websiteId} />
<RealtimeHeader websiteId={websiteId} data={currentData} />
<RealtimeChart className={styles.chart} data={realtimeData} unit="minute" />
<Grid>
<GridRow columns="one-two">
<RealtimeUrls websiteId={websiteId} websiteDomain={website?.domain} data={realtimeData} />
<RealtimeLog websiteId={websiteId} websiteDomain={website?.domain} data={realtimeData} />
</GridRow>
<GridRow columns="one-two">
<RealtimeCountries data={realtimeData?.countries} />
<WorldMap data={realtimeData?.countries} />
</GridRow>
</Grid>
</>
);
}
export default Realtime;

View file

@ -0,0 +1,16 @@
.container {
display: flex;
}
.chart {
margin-bottom: 30px;
}
.sticky {
position: fixed;
top: 0;
background: var(--base50);
border-bottom: 1px solid var(--base300);
z-index: var(--z-index-overlay);
padding: 10px 0;
}

View file

@ -0,0 +1,37 @@
import { useCallback } from 'react';
import ListTable from 'components/metrics/ListTable';
import useLocale from 'components/hooks/useLocale';
import useCountryNames from 'components/hooks/useCountryNames';
import useMessages from 'components/hooks/useMessages';
import classNames from 'classnames';
import styles from './RealtimeCountries.module.css';
export function RealtimeCountries({ data }) {
const { formatMessage, labels } = useMessages();
const { locale } = useLocale();
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}
/>
{countryNames[code]}
</span>
),
[countryNames, locale],
);
return (
<ListTable
title={formatMessage(labels.countries)}
metric={formatMessage(labels.visitors)}
data={data}
renderLabel={renderCountryName}
/>
);
}
export default RealtimeCountries;

View file

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

View file

@ -0,0 +1,41 @@
import MetricCard from 'components/metrics/MetricCard';
import useMessages from 'components/hooks/useMessages';
import styles from './RealtimeHeader.module.css';
export function RealtimeHeader({ data = {} }) {
const { formatMessage, labels } = useMessages();
const { pageviews, visitors, events, countries } = data;
return (
<div className={styles.header}>
<div className={styles.metrics}>
<MetricCard
className={styles.card}
label={formatMessage(labels.views)}
value={pageviews?.length}
hideComparison
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.visitors)}
value={visitors?.length}
hideComparison
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.events)}
value={events?.length}
hideComparison
/>
<MetricCard
className={styles.card}
label={formatMessage(labels.countries)}
value={countries?.length}
hideComparison
/>
</div>
</div>
);
}
export default RealtimeHeader;

View file

@ -0,0 +1,21 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.metrics {
display: flex;
flex-wrap: wrap;
}
.card {
justify-self: flex-start;
}
@media only screen and (max-width: 992px) {
.card {
flex-basis: calc(50% - 20px);
}
}

View file

@ -0,0 +1,31 @@
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import useApi from 'components/hooks/useApi';
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
import useMessages from 'components/hooks/useMessages';
export function RealtimeHome() {
const { formatMessage, labels, messages } = useMessages();
const { get, useQuery } = useApi();
const router = useRouter();
const { data, isLoading, error } = useQuery(['websites:me'], () => get('/me/websites'));
useEffect(() => {
if (data?.length) {
router.push(`realtime/${data[0].id}`);
}
}, [data, router]);
return (
<Page loading={isLoading || data?.length > 0} error={error}>
<PageHeader title={formatMessage(labels.realtime)} />
{data?.length === 0 && (
<EmptyPlaceholder message={formatMessage(messages.noWebsitesConfigured)} />
)}
</Page>
);
}
export default RealtimeHome;

View file

@ -0,0 +1,158 @@
import { useMemo, useState } from 'react';
import { StatusLight, Icon, Text } from 'react-basics';
import { FixedSizeList } from 'react-window';
import firstBy from 'thenby';
import FilterButtons from 'components/common/FilterButtons';
import Empty from 'components/common/Empty';
import useLocale from 'components/hooks/useLocale';
import useCountryNames from 'components/hooks/useCountryNames';
import { BROWSERS } from 'lib/constants';
import { stringToColor } from 'lib/format';
import { formatDate } from 'lib/date';
import { safeDecodeURI } from 'next-basics';
import Icons from 'components/icons';
import styles from './RealtimeLog.module.css';
import useMessages from 'components/hooks/useMessages';
const TYPE_ALL = 'all';
const TYPE_PAGEVIEW = 'pageview';
const TYPE_SESSION = 'session';
const TYPE_EVENT = 'event';
const icons = {
[TYPE_PAGEVIEW]: <Icons.Eye />,
[TYPE_SESSION]: <Icons.Visitor />,
[TYPE_EVENT]: <Icons.Bolt />,
};
export function RealtimeLog({ data, websiteDomain }) {
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
const { locale } = useLocale();
const countryNames = useCountryNames(locale);
const [filter, setFilter] = useState(TYPE_ALL);
const buttons = [
{
label: formatMessage(labels.all),
key: TYPE_ALL,
},
{
label: formatMessage(labels.views),
key: TYPE_PAGEVIEW,
},
{
label: formatMessage(labels.visitors),
key: TYPE_SESSION,
},
{
label: formatMessage(labels.events),
key: TYPE_EVENT,
},
];
const getTime = ({ createdAt }) => formatDate(new Date(createdAt), 'pp', locale);
const getColor = ({ id, sessionId }) => stringToColor(sessionId || id);
const getIcon = ({ __type }) => icons[__type];
const getDetail = log => {
const { __type, eventName, urlPath: url, browser, os, country, device } = log;
if (__type === TYPE_EVENT) {
return (
<FormattedMessage
{...messages.eventLog}
values={{
event: <b>{eventName || formatMessage(labels.unknown)}</b>,
url: (
<a
href={`//${websiteDomain}${url}`}
className={styles.link}
target="_blank"
rel="noreferrer noopener"
>
{url}
</a>
),
}}
/>
);
}
if (__type === TYPE_PAGEVIEW) {
return (
<a
href={`//${websiteDomain}${url}`}
className={styles.link}
target="_blank"
rel="noreferrer noopener"
>
{safeDecodeURI(url)}
</a>
);
}
if (__type === TYPE_SESSION) {
return (
<FormattedMessage
{...messages.visitorLog}
values={{
country: <b>{countryNames[country] || formatMessage(labels.unknown)}</b>,
browser: <b>{BROWSERS[browser]}</b>,
os: <b>{os}</b>,
device: <b>{formatMessage(labels[device] || labels.unknown)}</b>,
}}
/>
);
}
};
const Row = ({ index, style }) => {
const row = logs[index];
return (
<div className={styles.row} style={style}>
<div>
<StatusLight color={getColor(row)} />
</div>
<div className={styles.time}>{getTime(row)}</div>
<div className={styles.detail}>
<Icon className={styles.icon}>{getIcon(row)}</Icon>
<Text>{getDetail(row)}</Text>
</div>
</div>
);
};
const logs = useMemo(() => {
if (!data) {
return [];
}
const { pageviews, visitors, events } = data;
const logs = [...pageviews, ...visitors, ...events].sort(firstBy('createdAt', -1));
if (filter !== TYPE_ALL) {
return logs.filter(({ __type }) => __type === filter);
}
return logs;
}, [data, filter]);
return (
<div className={styles.table}>
<FilterButtons items={buttons} selectedKey={filter} onSelect={setFilter} />
<div className={styles.header}>{formatMessage(labels.activityLog)}</div>
<div className={styles.body}>
{logs?.length === 0 && <Empty />}
{logs?.length > 0 && (
<FixedSizeList height={500} itemCount={logs.length} itemSize={50}>
{Row}
</FixedSizeList>
)}
</div>
</div>
);
}
export default RealtimeLog;

View file

@ -0,0 +1,68 @@
.table {
font-size: var(--font-size-sm);
overflow: hidden;
height: 100%;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--font-size-md);
line-height: 40px;
font-weight: 700;
}
.row {
display: flex;
align-items: center;
gap: 10px;
height: 50px;
border-bottom: 1px solid var(--base300);
}
.body {
overflow: auto;
height: 100%;
}
.icon {
margin-right: 10px;
}
.time {
min-width: 60px;
overflow: hidden;
}
.website {
text-align: right;
padding: 0 20px;
}
.detail {
display: flex;
align-items: center;
flex: 1;
gap: 10px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.detail > span {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.row .link {
color: var(--base900);
text-decoration: none;
}
.row .link:hover {
color: var(--primary400);
}

View file

@ -0,0 +1,104 @@
import { useMemo, useState } from 'react';
import { ButtonGroup, Button, Flexbox } from 'react-basics';
import firstBy from 'thenby';
import { percentFilter } from 'lib/filters';
import ListTable from 'components/metrics/ListTable';
import { FILTER_PAGES, FILTER_REFERRERS } from 'lib/constants';
import useMessages from 'components/hooks/useMessages';
export function RealtimeUrls({ websiteDomain, data = {} }) {
const { formatMessage, labels } = useMessages();
const { pageviews } = data;
const [filter, setFilter] = useState(FILTER_REFERRERS);
const limit = 15;
const buttons = [
{
label: formatMessage(labels.referrers),
key: FILTER_REFERRERS,
},
{
label: formatMessage(labels.pages),
key: FILTER_PAGES,
},
];
const renderLink = ({ x }) => {
const domain = x.startsWith('/') ? websiteDomain : '';
return (
<a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener">
{x}
</a>
);
};
const [referrers = [], pages = []] = useMemo(() => {
if (pageviews) {
const referrers = percentFilter(
pageviews
.reduce((arr, { referrerDomain }) => {
if (referrerDomain) {
const row = arr.find(({ x }) => x === referrerDomain);
if (!row) {
arr.push({ x: referrerDomain, y: 1 });
} else {
row.y += 1;
}
}
return arr;
}, [])
.sort(firstBy('y', -1))
.slice(0, limit),
);
const pages = percentFilter(
pageviews
.reduce((arr, { urlPath }) => {
const row = arr.find(({ x }) => x === urlPath);
if (!row) {
arr.push({ x: urlPath, y: 1 });
} else {
row.y += 1;
}
return arr;
}, [])
.sort(firstBy('y', -1))
.slice(0, limit),
);
return [referrers, pages];
}
return [];
}, [pageviews]);
return (
<>
<Flexbox justifyContent="center">
<ButtonGroup items={buttons} selectedKey={filter} onSelect={setFilter}>
{({ key, label }) => <Button key={key}>{label}</Button>}
</ButtonGroup>
</Flexbox>
{filter === FILTER_REFERRERS && (
<ListTable
title={formatMessage(labels.referrers)}
metric={formatMessage(labels.views)}
renderLabel={renderLink}
data={referrers}
/>
)}
{filter === FILTER_PAGES && (
<ListTable
title={formatMessage(labels.pages)}
metric={formatMessage(labels.views)}
renderLabel={renderLink}
data={pages}
/>
)}
</>
);
}
export default RealtimeUrls;

View file

@ -0,0 +1,9 @@
import Realtime from './Realtime';
export default function WebsiteRealtimePage({ params: { id } }) {
if (!id) {
return null;
}
return <Realtime websiteId={id} />;
}

View file

@ -0,0 +1,61 @@
'use client';
import Page from 'components/layout/Page';
import Empty from 'components/common/Empty';
import ReportsTable from '../../../../(main)/reports/ReportsTable';
import { useMessages, useWebsiteReports } from 'components/hooks';
import Link from 'next/link';
import { Button, Flexbox, Icon, Icons, Text } from 'react-basics';
import WebsiteHeader from '../WebsiteHeader';
export function WebsiteReports({ websiteId }) {
const { formatMessage, labels } = useMessages();
const {
reports,
error,
isLoading,
deleteReport,
filter,
handleFilterChange,
handlePageChange,
handlePageSizeChange,
} = useWebsiteReports(websiteId);
const hasData = (reports && reports.data.length !== 0) || filter;
const handleDelete = async id => {
await deleteReport(id);
};
if (isLoading || error) {
return <Page loading={isLoading} error={error} />;
}
return (
<>
<WebsiteHeader websiteId={websiteId} />
<Flexbox alignItems="center" justifyContent="end">
<Link href={`/reports/create`}>
<Button variant="primary">
<Icon>
<Icons.Plus />
</Icon>
<Text>{formatMessage(labels.createReport)}</Text>
</Button>
</Link>
</Flexbox>
{hasData && (
<ReportsTable
data={reports}
onDelete={handleDelete}
onFilterChange={handleFilterChange}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
filterValue={filter}
/>
)}
{!hasData && <Empty />}
</>
);
}
export default WebsiteReports;

View file

@ -0,0 +1,9 @@
import WebsiteReports from './WebsiteReports';
export default function WebsiteReportsPage({ params: { id } }) {
if (!id) {
return null;
}
return <WebsiteReports websiteId={id} />;
}

View file

@ -0,0 +1,12 @@
'use client';
import WebsitesHeader from '../../(main)/settings/websites/WebsitesHeader';
import WebsitesBrowse from './WebsitesBrowse';
export default function WebsitesPage() {
return (
<>
<WebsitesHeader />
<WebsitesBrowse />
</>
);
}