mirror of
https://github.com/umami-software/umami.git
synced 2026-02-15 10:05:36 +01:00
Renamed (app) folder to (main).
This commit is contained in:
parent
5c15778c9b
commit
c990459238
167 changed files with 48 additions and 114 deletions
41
src/app/(main)/websites/WebsiteTableView.js
Normal file
41
src/app/(main)/websites/WebsiteTableView.js
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/app/(main)/websites/WebsitesBrowse.js
Normal file
30
src/app/(main)/websites/WebsitesBrowse.js
Normal 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;
|
||||
51
src/app/(main)/websites/[id]/WebsiteChart.js
Normal file
51
src/app/(main)/websites/[id]/WebsiteChart.js
Normal 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;
|
||||
17
src/app/(main)/websites/[id]/WebsiteChart.module.css
Normal file
17
src/app/(main)/websites/[id]/WebsiteChart.module.css
Normal 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;
|
||||
}
|
||||
49
src/app/(main)/websites/[id]/WebsiteChartList.js
Normal file
49
src/app/(main)/websites/[id]/WebsiteChartList.js
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/app/(main)/websites/[id]/WebsiteDetails.js
Normal file
45
src/app/(main)/websites/[id]/WebsiteDetails.js
Normal 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} />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
78
src/app/(main)/websites/[id]/WebsiteHeader.js
Normal file
78
src/app/(main)/websites/[id]/WebsiteHeader.js
Normal 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;
|
||||
59
src/app/(main)/websites/[id]/WebsiteHeader.module.css
Normal file
59
src/app/(main)/websites/[id]/WebsiteHeader.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
129
src/app/(main)/websites/[id]/WebsiteMenuView.js
Normal file
129
src/app/(main)/websites/[id]/WebsiteMenuView.js
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/app/(main)/websites/[id]/WebsiteMenuView.module.css
Normal file
25
src/app/(main)/websites/[id]/WebsiteMenuView.module.css
Normal 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);
|
||||
}
|
||||
162
src/app/(main)/websites/[id]/WebsiteMetricsBar.js
Normal file
162
src/app/(main)/websites/[id]/WebsiteMetricsBar.js
Normal 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;
|
||||
40
src/app/(main)/websites/[id]/WebsiteMetricsBar.module.css
Normal file
40
src/app/(main)/websites/[id]/WebsiteMetricsBar.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
37
src/app/(main)/websites/[id]/event-data/EventDataTable.js
Normal file
37
src/app/(main)/websites/[id]/event-data/EventDataTable.js
Normal 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;
|
||||
|
|
@ -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;
|
||||
42
src/app/(main)/websites/[id]/event-data/WebsiteEventData.js
Normal file
42
src/app/(main)/websites/[id]/event-data/WebsiteEventData.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.container a {
|
||||
color: var(--font-color100);
|
||||
}
|
||||
|
||||
.container a:hover {
|
||||
color: var(--primary400);
|
||||
}
|
||||
15
src/app/(main)/websites/[id]/event-data/page.js
Normal file
15
src/app/(main)/websites/[id]/event-data/page.js
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
src/app/(main)/websites/[id]/page.tsx
Normal file
5
src/app/(main)/websites/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import WebsiteDetails from './WebsiteDetails';
|
||||
|
||||
export default function WebsiteReportsPage({ params: { id } }) {
|
||||
return <WebsiteDetails websiteId={id} />;
|
||||
}
|
||||
116
src/app/(main)/websites/[id]/realtime/Realtime.js
Normal file
116
src/app/(main)/websites/[id]/realtime/Realtime.js
Normal 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;
|
||||
16
src/app/(main)/websites/[id]/realtime/Realtime.module.css
Normal file
16
src/app/(main)/websites/[id]/realtime/Realtime.module.css
Normal 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;
|
||||
}
|
||||
37
src/app/(main)/websites/[id]/realtime/RealtimeCountries.js
Normal file
37
src/app/(main)/websites/[id]/realtime/RealtimeCountries.js
Normal 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;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
41
src/app/(main)/websites/[id]/realtime/RealtimeHeader.js
Normal file
41
src/app/(main)/websites/[id]/realtime/RealtimeHeader.js
Normal 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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
31
src/app/(main)/websites/[id]/realtime/RealtimeHome.js
Normal file
31
src/app/(main)/websites/[id]/realtime/RealtimeHome.js
Normal 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;
|
||||
158
src/app/(main)/websites/[id]/realtime/RealtimeLog.js
Normal file
158
src/app/(main)/websites/[id]/realtime/RealtimeLog.js
Normal 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;
|
||||
68
src/app/(main)/websites/[id]/realtime/RealtimeLog.module.css
Normal file
68
src/app/(main)/websites/[id]/realtime/RealtimeLog.module.css
Normal 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);
|
||||
}
|
||||
104
src/app/(main)/websites/[id]/realtime/RealtimeUrls.js
Normal file
104
src/app/(main)/websites/[id]/realtime/RealtimeUrls.js
Normal 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;
|
||||
9
src/app/(main)/websites/[id]/realtime/page.tsx
Normal file
9
src/app/(main)/websites/[id]/realtime/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import Realtime from './Realtime';
|
||||
|
||||
export default function WebsiteRealtimePage({ params: { id } }) {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Realtime websiteId={id} />;
|
||||
}
|
||||
61
src/app/(main)/websites/[id]/reports/WebsiteReports.js
Normal file
61
src/app/(main)/websites/[id]/reports/WebsiteReports.js
Normal 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;
|
||||
9
src/app/(main)/websites/[id]/reports/page.tsx
Normal file
9
src/app/(main)/websites/[id]/reports/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import WebsiteReports from './WebsiteReports';
|
||||
|
||||
export default function WebsiteReportsPage({ params: { id } }) {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WebsiteReports websiteId={id} />;
|
||||
}
|
||||
12
src/app/(main)/websites/page.js
Normal file
12
src/app/(main)/websites/page.js
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue