mirror of
https://github.com/umami-software/umami.git
synced 2026-02-07 06:07:17 +01:00
Refactored to use app folder.
This commit is contained in:
parent
40cfcd41e9
commit
9a52cdd2e1
258 changed files with 2025 additions and 2258 deletions
|
|
@ -1,17 +1,36 @@
|
|||
import { ReactNode, Dispatch, SetStateAction } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Banner, Loading, SearchField } from 'react-basics';
|
||||
import { useMessages } from 'components/hooks';
|
||||
import Empty from 'components/common/Empty';
|
||||
import Pager from 'components/common/Pager';
|
||||
import styles from './DataTable.module.css';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const DEFAULT_SEARCH_DELAY = 600;
|
||||
|
||||
export const DataTableStyles = styles;
|
||||
export interface DataTableProps {
|
||||
result: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
count: number;
|
||||
data: any[];
|
||||
};
|
||||
params: {
|
||||
query: string;
|
||||
page: number;
|
||||
};
|
||||
setParams: Dispatch<SetStateAction<{ query: string; page: number }>>;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
searchDelay?: number;
|
||||
showSearch?: boolean;
|
||||
showPaging?: boolean;
|
||||
children: ReactNode | ((data: any) => ReactNode);
|
||||
}
|
||||
|
||||
export function DataTable({
|
||||
data = {},
|
||||
params = {},
|
||||
result,
|
||||
params,
|
||||
setParams,
|
||||
isLoading,
|
||||
error,
|
||||
|
|
@ -19,11 +38,11 @@ export function DataTable({
|
|||
showSearch = true,
|
||||
showPaging = true,
|
||||
children,
|
||||
}) {
|
||||
}: DataTableProps) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { pageSize, count } = data;
|
||||
const { query, page } = params;
|
||||
const hasData = Boolean(!isLoading && data?.data?.length);
|
||||
const { pageSize, count } = result || {};
|
||||
const { query, page } = params || {};
|
||||
const hasData = Boolean(!isLoading && result?.data?.length);
|
||||
const noResults = Boolean(!isLoading && query && !hasData);
|
||||
|
||||
const handleSearch = query => {
|
||||
|
|
@ -53,7 +72,7 @@ export function DataTable({
|
|||
<div
|
||||
className={classNames(styles.body, { [styles.status]: isLoading || noResults || !hasData })}
|
||||
>
|
||||
{hasData && typeof children === 'function' ? children(data) : children}
|
||||
{hasData ? (typeof children === 'function' ? children(result) : children) : null}
|
||||
{isLoading && <Loading icon="dots" />}
|
||||
{!isLoading && !hasData && !query && (
|
||||
<Empty message={formatMessage(messages.noDataAvailable)} />
|
||||
|
|
@ -2,7 +2,12 @@ import classNames from 'classnames';
|
|||
import styles from './Empty.module.css';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function Empty({ message, className }) {
|
||||
export interface EmptyProps {
|
||||
message?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Empty({ message, className }: EmptyProps) {
|
||||
const { formatMessage, messages } = useMessages();
|
||||
|
||||
return (
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { createPortal } from 'react-dom';
|
||||
import classNames from 'classnames';
|
||||
import { useRouter } from 'next/router';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import styles from './MobileMenu.module.css';
|
||||
|
||||
export function MobileMenu({ items = [], onClose }) {
|
||||
const { pathname } = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const Items = ({ items, className }) => (
|
||||
<div className={classNames(styles.items, className)}>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
'use client';
|
||||
import { useEffect, useCallback, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button, Row, Column } from 'react-basics';
|
||||
|
|
@ -6,12 +7,12 @@ import useStore, { checkVersion } from 'store/version';
|
|||
import { REPO_URL, VERSION_CHECK } from 'lib/constants';
|
||||
import styles from './UpdateNotice.module.css';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import { useRouter } from 'next/router';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export function UpdateNotice({ user, config }) {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { latest, checked, hasUpdate, releaseUrl } = useStore();
|
||||
const { pathname } = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [dismissed, setDismissed] = useState(checked);
|
||||
const allowUpdate =
|
||||
user?.isAdmin &&
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
|
||||
import classNames from 'classnames';
|
||||
import { colord } from 'colord';
|
||||
|
|
@ -14,7 +13,6 @@ import { percentFilter } from 'lib/filters';
|
|||
import styles from './WorldMap.module.css';
|
||||
|
||||
export function WorldMap({ data, className }) {
|
||||
const { basePath } = useRouter();
|
||||
const [tooltip, setTooltipPopup] = useState();
|
||||
const { theme, colors } = useTheme();
|
||||
const { locale } = useLocale();
|
||||
|
|
@ -54,7 +52,7 @@ export function WorldMap({ data, className }) {
|
|||
>
|
||||
<ComposableMap projection="geoMercator">
|
||||
<ZoomableGroup zoom={0.8} minZoom={0.7} center={[0, 40]}>
|
||||
<Geographies geography={`${basePath}${MAP_FILE}`}>
|
||||
<Geographies geography={`${process.env.basePath}${MAP_FILE}`}>
|
||||
{({ geographies }) => {
|
||||
return geographies.map(geo => {
|
||||
const code = ISO_COUNTRIES[geo.id];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import * as reactQuery from '@tanstack/react-query';
|
||||
import { useApi as nextUseApi } from 'next-basics';
|
||||
import { getClientAuthToken } from 'lib/client';
|
||||
|
|
@ -8,12 +7,11 @@ import useStore from 'store/app';
|
|||
const selector = state => state.shareToken;
|
||||
|
||||
export function useApi() {
|
||||
const { basePath } = useRouter();
|
||||
const shareToken = useStore(selector);
|
||||
|
||||
const { get, post, put, del } = nextUseApi(
|
||||
{ authorization: `Bearer ${getClientAuthToken()}`, [SHARE_TOKEN_HEADER]: shareToken?.token },
|
||||
basePath,
|
||||
process.env.basePath,
|
||||
);
|
||||
|
||||
return { get, post, put, del, ...reactQuery };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { httpGet } from 'next-basics';
|
||||
import enUS from 'public/intl/country/en-US.json';
|
||||
|
||||
|
|
@ -9,10 +8,9 @@ const countryNames = {
|
|||
|
||||
export function useCountryNames(locale) {
|
||||
const [list, setList] = useState(countryNames[locale] || enUS);
|
||||
const { basePath } = useRouter();
|
||||
|
||||
async function loadData(locale) {
|
||||
const { data } = await httpGet(`${basePath}/intl/country/${locale}.json`);
|
||||
const { data } = await httpGet(`${process.env.basePath}/intl/country/${locale}.json`);
|
||||
|
||||
if (data) {
|
||||
countryNames[locale] = data;
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useApi } from 'components/hooks/useApi';
|
||||
|
||||
export function useFilterQuery(key, fn, options) {
|
||||
const [params, setParams] = useState({
|
||||
query: '',
|
||||
page: 1,
|
||||
});
|
||||
const { useQuery } = useApi();
|
||||
|
||||
const result = useQuery([...key, params], fn.bind(null, params), options);
|
||||
|
||||
return { ...result, params, setParams };
|
||||
}
|
||||
|
||||
export default useFilterQuery;
|
||||
26
src/components/hooks/useFilterQuery.ts
Normal file
26
src/components/hooks/useFilterQuery.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import { useApi } from 'components/hooks/useApi';
|
||||
|
||||
export function useFilterQuery(key: any[], fn, options?: any) {
|
||||
const [params, setParams] = useState({
|
||||
query: '',
|
||||
page: 1,
|
||||
});
|
||||
const { useQuery } = useApi();
|
||||
|
||||
const result = useQuery<{
|
||||
page: number;
|
||||
pageSize: number;
|
||||
count: number;
|
||||
data: any[];
|
||||
}>([...key, params], fn.bind(null, params), options);
|
||||
|
||||
const getProps = useCallback(() => {
|
||||
const { data, isLoading, error } = result;
|
||||
return { result: data, isLoading, error, params, setParams };
|
||||
}, [result, params, setParams]);
|
||||
|
||||
return { ...result, getProps };
|
||||
}
|
||||
|
||||
export default useFilterQuery;
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { httpGet } from 'next-basics';
|
||||
import enUS from 'public/intl/language/en-US.json';
|
||||
|
||||
|
|
@ -9,10 +8,9 @@ const languageNames = {
|
|||
|
||||
export function useLanguageNames(locale) {
|
||||
const [list, setList] = useState(languageNames[locale] || enUS);
|
||||
const { basePath } = useRouter();
|
||||
|
||||
async function loadData(locale) {
|
||||
const { data } = await httpGet(`${basePath}/intl/language/${locale}.json`);
|
||||
const { data } = await httpGet(`${process.env.basePath}/intl/language/${locale}.json`);
|
||||
|
||||
if (data) {
|
||||
languageNames[locale] = data;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { httpGet, setItem } from 'next-basics';
|
||||
import { LOCALE_CONFIG } from 'lib/constants';
|
||||
import { getDateLocale, getTextDirection } from 'lib/lang';
|
||||
|
|
@ -15,13 +14,12 @@ const selector = state => state.locale;
|
|||
|
||||
export function useLocale() {
|
||||
const locale = useStore(selector);
|
||||
const { basePath } = useRouter();
|
||||
const forceUpdate = useForceUpdate();
|
||||
const dir = getTextDirection(locale);
|
||||
const dateLocale = getDateLocale(locale);
|
||||
|
||||
async function loadMessages(locale) {
|
||||
const { ok, data } = await httpGet(`${basePath}/intl/messages/${locale}.json`);
|
||||
const { ok, data } = await httpGet(`${process.env.basePath}/intl/messages/${locale}.json`);
|
||||
|
||||
if (ok) {
|
||||
messages[locale] = data;
|
||||
|
|
|
|||
|
|
@ -1,30 +1,24 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { buildUrl } from 'next-basics';
|
||||
|
||||
export function usePageQuery() {
|
||||
const router = useRouter();
|
||||
const { pathname, search } = location;
|
||||
const { asPath } = router;
|
||||
const pathname = usePathname();
|
||||
const params = useSearchParams();
|
||||
|
||||
const query = useMemo(() => {
|
||||
if (!search) {
|
||||
return {};
|
||||
const obj = {};
|
||||
|
||||
for (const [key, value] of params.entries()) {
|
||||
obj[key] = decodeURIComponent(value);
|
||||
}
|
||||
|
||||
const params = search.substring(1).split('&');
|
||||
|
||||
return params.reduce((obj, item) => {
|
||||
const [key, value] = item.split('=');
|
||||
|
||||
obj[key] = decodeURIComponent(value);
|
||||
|
||||
return obj;
|
||||
}, {});
|
||||
}, [search]);
|
||||
return obj;
|
||||
}, [params]);
|
||||
|
||||
function resolveUrl(params, reset) {
|
||||
return buildUrl(asPath.split('?')[0], { ...(reset ? {} : query), ...params });
|
||||
return buildUrl(pathname, { ...(reset ? {} : query) });
|
||||
}
|
||||
|
||||
return { pathname, query, resolveUrl, router };
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
|
||||
export function useRequireLogin(handler: (data?: object) => void) {
|
||||
const { basePath } = useRouter();
|
||||
export function useRequireLogin(handler?: (data?: object) => void) {
|
||||
const { get } = useApi();
|
||||
const { user, setUser } = useUser();
|
||||
|
||||
|
|
@ -15,7 +13,7 @@ export function useRequireLogin(handler: (data?: object) => void) {
|
|||
|
||||
setUser(typeof handler === 'function' ? handler(data) : (data as any)?.user);
|
||||
} catch {
|
||||
location.href = `${basePath}/login`;
|
||||
location.href = `${process.env.basePath}/login`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import useMessages from 'components/hooks/useMessages';
|
|||
export function LogoutButton({ tooltipPosition = 'top' }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
return (
|
||||
<Link href="/src/pages/logout">
|
||||
<Link href="/src/app/logout/logout">
|
||||
<TooltipPopup label={formatMessage(labels.logout)} position={tooltipPosition}>
|
||||
<Button variant="quiet">
|
||||
<Icon>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Icon, Button, PopupTrigger, Popup, Menu, Item, Text } from 'react-basics';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Icons from 'components/icons';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Button, Icon, PopupTrigger, Popup, Form, FormRow } from 'react-basics';
|
||||
import TimezoneSetting from 'components/pages/settings/profile/TimezoneSetting';
|
||||
import DateRangeSetting from 'components/pages/settings/profile/DateRangeSetting';
|
||||
import TimezoneSetting from 'app/(app)/settings/profile/TimezoneSetting';
|
||||
import DateRangeSetting from 'app/(app)/settings/profile/DateRangeSetting';
|
||||
import Icons from 'components/icons';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import styles from './SettingsButton.module.css';
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
import { Container } from 'react-basics';
|
||||
import Head from 'next/head';
|
||||
import NavBar from 'components/layout/NavBar';
|
||||
import UpdateNotice from 'components/common/UpdateNotice';
|
||||
import { useRequireLogin, useConfig } from 'components/hooks';
|
||||
import styles from './AppLayout.module.css';
|
||||
|
||||
export function AppLayout({ title, children }) {
|
||||
const { user } = useRequireLogin();
|
||||
const config = useConfig();
|
||||
|
||||
if (!user || !config || config?.uiDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.layout}>
|
||||
<UpdateNotice user={user} config={config} />
|
||||
<Head>
|
||||
<title>{title ? `${title} | umami` : 'umami'}</title>
|
||||
</Head>
|
||||
<nav className={styles.nav}>
|
||||
<NavBar />
|
||||
</nav>
|
||||
<main className={styles.body}>
|
||||
<Container>{children}</Container>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppLayout;
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
.layout {
|
||||
display: grid;
|
||||
grid-template-rows: max-content 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav {
|
||||
height: 60px;
|
||||
width: 100vw;
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 2;
|
||||
z-index: var(--z-index-popup);
|
||||
}
|
||||
|
||||
.body {
|
||||
grid-column: 1;
|
||||
grid-row: 2 / 3;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 60px);
|
||||
overflow-y: auto;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { CURRENT_VERSION, HOMEPAGE_URL } from 'lib/constants';
|
||||
import styles from './Footer.module.css';
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className={styles.footer}>
|
||||
<a href={HOMEPAGE_URL}>
|
||||
<b>umami</b> {`v${CURRENT_VERSION}`}
|
||||
</a>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
.footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 30px;
|
||||
margin: 40px 0;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--font-color100);
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { Column, Icon, Row, Text } from 'react-basics';
|
||||
import Link from 'next/link';
|
||||
import LanguageButton from 'components/input/LanguageButton';
|
||||
import ThemeButton from 'components/input/ThemeButton';
|
||||
import SettingsButton from 'components/input/SettingsButton';
|
||||
import Icons from 'components/icons';
|
||||
import styles from './Header.module.css';
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<Row className={styles.row}>
|
||||
<Column>
|
||||
<Link href="https://umami.is" target="_blank" className={styles.title}>
|
||||
<Icon size="lg">
|
||||
<Icons.Logo />
|
||||
</Icon>
|
||||
<Text>umami</Text>
|
||||
</Link>
|
||||
</Column>
|
||||
<Column className={styles.buttons}>
|
||||
<ThemeButton tooltipPosition="bottom" />
|
||||
<LanguageButton tooltipPosition="bottom" menuPosition="bottom" />
|
||||
<SettingsButton />
|
||||
</Column>
|
||||
</Row>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
color: var(--font-color100) !important;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.header .buttons {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.links {
|
||||
order: 2;
|
||||
margin: 20px 0;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.buttons,
|
||||
.links {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { Icon, Text, Row, Column } from 'react-basics';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import classNames from 'classnames';
|
||||
import Icons from 'components/icons';
|
||||
import ThemeButton from 'components/input/ThemeButton';
|
||||
import LanguageButton from 'components/input/LanguageButton';
|
||||
import ProfileButton from 'components/input/ProfileButton';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import HamburgerButton from 'components/common/HamburgerButton';
|
||||
import styles from './NavBar.module.css';
|
||||
|
||||
export function NavBar() {
|
||||
const { pathname } = useRouter();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const links = [
|
||||
{ label: formatMessage(labels.dashboard), url: '/dashboard' },
|
||||
{ label: formatMessage(labels.websites), url: '/websites' },
|
||||
{ label: formatMessage(labels.reports), url: '/reports' },
|
||||
{ label: formatMessage(labels.settings), url: '/settings' },
|
||||
].filter(n => n);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.navbar)}>
|
||||
<Row>
|
||||
<Column className={styles.left}>
|
||||
<div className={styles.logo}>
|
||||
<Icon size="lg">
|
||||
<Icons.Logo />
|
||||
</Icon>
|
||||
<Text className={styles.text}>umami</Text>
|
||||
</div>
|
||||
<div className={styles.links}>
|
||||
{links.map(({ url, label }) => {
|
||||
return (
|
||||
<Link
|
||||
key={url}
|
||||
href={url}
|
||||
className={classNames({ [styles.selected]: pathname.startsWith(url) })}
|
||||
>
|
||||
<Text>{label}</Text>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Column>
|
||||
<Column className={styles.right}>
|
||||
<div className={styles.actions}>
|
||||
<ThemeButton />
|
||||
<LanguageButton />
|
||||
<ProfileButton />
|
||||
</div>
|
||||
<div className={styles.mobile}>
|
||||
<HamburgerButton />
|
||||
</div>
|
||||
</Column>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NavBar;
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
.navbar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
background: var(--base75);
|
||||
border-bottom: 1px solid var(--base300);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 30px;
|
||||
padding: 0 40px;
|
||||
flex: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.links a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
line-height: 60px;
|
||||
color: var(--font-color200);
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.links span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.links a:hover {
|
||||
color: var(--font-color100);
|
||||
border-bottom: 2px solid var(--primary400);
|
||||
}
|
||||
|
||||
.links .selected {
|
||||
color: var(--font-color100);
|
||||
border-bottom: 2px solid var(--primary400);
|
||||
}
|
||||
|
||||
.actions,
|
||||
.mobile {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.links,
|
||||
.actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { Icon, Text, TooltipPopup } from 'react-basics';
|
||||
import classNames from 'classnames';
|
||||
import { useRouter } from 'next/router';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Icons from 'components/icons';
|
||||
import styles from './NavGroup.module.css';
|
||||
|
|
@ -13,7 +13,7 @@ export function NavGroup({
|
|||
allowExpand = true,
|
||||
minimized = false,
|
||||
}) {
|
||||
const { pathname } = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
const handleExpand = () => setExpanded(state => !state);
|
||||
|
|
|
|||
|
|
@ -5,4 +5,7 @@
|
|||
background: var(--base50);
|
||||
position: relative;
|
||||
height: 100%;
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,27 @@
|
|||
import { ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Banner, Loading } from 'react-basics';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import styles from './Page.module.css';
|
||||
|
||||
export function Page({ className, error, loading, children }) {
|
||||
export function Page({
|
||||
className,
|
||||
error,
|
||||
isLoading,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
error?: unknown;
|
||||
isLoading?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const { formatMessage, messages } = useMessages();
|
||||
|
||||
if (error) {
|
||||
return <Banner variant="error">{formatMessage(messages.error)}</Banner>;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
if (isLoading) {
|
||||
return <Loading icon="spinner" size="xl" position="page" />;
|
||||
}
|
||||
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import styles from './PageHeader.module.css';
|
||||
|
||||
export function PageHeader({ title, children, className }) {
|
||||
export interface PageHeaderProps {
|
||||
title?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, className, children }: PageHeaderProps) {
|
||||
return (
|
||||
<div className={classNames(styles.header, className)}>
|
||||
{title && <div className={styles.title}>{title}</div>}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { Column, Row } from 'react-basics';
|
||||
import styles from './ReportsLayout.module.css';
|
||||
|
||||
export function ReportsLayout({ children, filter, header }) {
|
||||
return (
|
||||
<>
|
||||
<Row>{header}</Row>
|
||||
<Row>
|
||||
{filter && (
|
||||
<Column className={styles.filter} defaultSize={12} md={4} lg={3} xl={3}>
|
||||
<h2>Filters</h2>
|
||||
{filter}
|
||||
</Column>
|
||||
)}
|
||||
<Column className={styles.content} defaultSize={12} md={8} lg={9} xl={9}>
|
||||
{children}
|
||||
</Column>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportsLayout;
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
.filter {
|
||||
margin-top: 30px;
|
||||
min-width: 200px;
|
||||
max-width: 100vw;
|
||||
padding: 10px;
|
||||
background: var(--base50);
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.filter h2 {
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.menu {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { Row, Column } from 'react-basics';
|
||||
import { useRouter } from 'next/router';
|
||||
import SideNav from './SideNav';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import styles from './SettingsLayout.module.css';
|
||||
|
||||
export function SettingsLayout({ children }) {
|
||||
const { user } = useUser();
|
||||
const { pathname } = useRouter();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const cloudMode = Boolean(process.env.cloudMode);
|
||||
|
||||
const items = [
|
||||
{ key: 'websites', label: formatMessage(labels.websites), url: '/settings/websites' },
|
||||
{ key: 'teams', label: formatMessage(labels.teams), url: '/settings/teams' },
|
||||
user.isAdmin && { key: 'users', label: formatMessage(labels.users), url: '/settings/users' },
|
||||
{ key: 'profile', label: formatMessage(labels.profile), url: '/settings/profile' },
|
||||
].filter(n => n);
|
||||
|
||||
const getKey = () => items.find(({ url }) => pathname === url)?.key;
|
||||
|
||||
return (
|
||||
<Row>
|
||||
{!cloudMode && (
|
||||
<Column className={styles.menu} defaultSize={12} md={4} lg={3} xl={2}>
|
||||
<SideNav items={items} shallow={true} selectedKey={getKey()} />
|
||||
</Column>
|
||||
)}
|
||||
<Column className={styles.content} defaultSize={12} md={8} lg={9} xl={10}>
|
||||
{children}
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsLayout;
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 40px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { Container } from 'react-basics';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
|
||||
export function ShareLayout({ children }) {
|
||||
return (
|
||||
<Container>
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShareLayout;
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import { Menu, Item } from 'react-basics';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import styles from './SideNav.module.css';
|
||||
|
||||
export function SideNav({
|
||||
selectedKey,
|
||||
items,
|
||||
shallow = true,
|
||||
scroll = false,
|
||||
onSelect = () => {},
|
||||
}) {
|
||||
const { asPath } = useRouter();
|
||||
return (
|
||||
<Menu items={items} selectedKey={selectedKey} className={styles.menu} onSelect={onSelect}>
|
||||
{({ key, label, url }) => (
|
||||
<Item
|
||||
key={key}
|
||||
className={classNames(styles.item, { [styles.selected]: asPath.startsWith(url) })}
|
||||
>
|
||||
<Link href={url} shallow={shallow} scroll={scroll}>
|
||||
{label}
|
||||
</Link>
|
||||
</Item>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default SideNav;
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.item a {
|
||||
color: var(--font-color100);
|
||||
flex: 1;
|
||||
padding: var(--size300) var(--size600);
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 0;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.selected {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import MetricsTable from 'components/metrics/MetricsTable';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
|
@ -6,14 +5,13 @@ import useFormat from 'components/hooks/useFormat';
|
|||
|
||||
export function BrowsersTable({ websiteId, ...props }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { basePath } = useRouter();
|
||||
const { formatBrowser } = useFormat();
|
||||
|
||||
function renderLink({ x: browser }) {
|
||||
return (
|
||||
<FilterLink id="browser" value={browser} label={formatBrowser(browser)}>
|
||||
<img
|
||||
src={`${basePath}/images/browsers/${browser || 'unknown'}.png`}
|
||||
src={`${process.env.basePath}/images/browsers/${browser || 'unknown'}.png`}
|
||||
alt={browser}
|
||||
width={16}
|
||||
height={16}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import MetricsTable from './MetricsTable';
|
||||
import { emptyFilter } from 'lib/filters';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
|
|
@ -9,7 +8,6 @@ import useCountryNames from 'components/hooks/useCountryNames';
|
|||
export function CitiesTable({ websiteId, ...props }) {
|
||||
const { locale } = useLocale();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { basePath } = useRouter();
|
||||
const countryNames = useCountryNames(locale);
|
||||
|
||||
const renderLabel = (city, country) => {
|
||||
|
|
@ -22,7 +20,7 @@ export function CitiesTable({ websiteId, ...props }) {
|
|||
<FilterLink id="city" value={city} label={renderLabel(city, country)}>
|
||||
{country && (
|
||||
<img
|
||||
src={`${basePath}/images/flags/${country?.toLowerCase() || 'xx'}.png`}
|
||||
src={`${process.env.basePath}/images/flags/${country?.toLowerCase() || 'xx'}.png`}
|
||||
alt={country}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import useCountryNames from 'components/hooks/useCountryNames';
|
||||
import { useLocale, useMessages, useFormat } from 'components/hooks';
|
||||
|
|
@ -8,7 +7,6 @@ export function CountriesTable({ websiteId, ...props }) {
|
|||
const { locale } = useLocale();
|
||||
const countryNames = useCountryNames(locale);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { basePath } = useRouter();
|
||||
const { formatCountry } = useFormat();
|
||||
|
||||
function renderLink({ x: code }) {
|
||||
|
|
@ -19,7 +17,10 @@ export function CountriesTable({ websiteId, ...props }) {
|
|||
value={countryNames[code] && code}
|
||||
label={formatCountry(code)}
|
||||
>
|
||||
<img src={`${basePath}/images/flags/${code?.toLowerCase() || 'xx'}.png`} alt={code} />
|
||||
<img
|
||||
src={`${process.env.basePath}/images/flags/${code?.toLowerCase() || 'xx'}.png`}
|
||||
alt={code}
|
||||
/>
|
||||
</FilterLink>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,17 @@
|
|||
import MetricsTable from './MetricsTable';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useFormat } from 'components/hooks';
|
||||
|
||||
export function DevicesTable({ websiteId, ...props }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { basePath } = useRouter();
|
||||
const { formatDevice } = useFormat();
|
||||
|
||||
function renderLink({ x: device }) {
|
||||
return (
|
||||
<FilterLink id="device" value={labels[device] && device} label={formatDevice(device)}>
|
||||
<img
|
||||
src={`${basePath}/images/device/${device?.toLowerCase() || 'unknown'}.png`}
|
||||
src={`${process.env.basePath}/images/device/${device?.toLowerCase() || 'unknown'}.png`}
|
||||
alt={device}
|
||||
width={16}
|
||||
height={16}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ export function MetricsTable({
|
|||
const [{ startDate, endDate, modified }] = useDateRange(websiteId);
|
||||
const {
|
||||
resolveUrl,
|
||||
router,
|
||||
query: { url, referrer, title, os, browser, device, country, region, city },
|
||||
} = usePageQuery();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
|
@ -104,7 +103,7 @@ export function MetricsTable({
|
|||
{data && !error && <ListTable {...props} data={filteredData} className={className} />}
|
||||
<div className={styles.footer}>
|
||||
{data && !error && limit && (
|
||||
<Link href={router.pathname} as={resolveUrl({ view: type })}>
|
||||
<Link href={resolveUrl({ view: type })}>
|
||||
<Button variant="quiet">
|
||||
<Text>{formatMessage(labels.more)}</Text>
|
||||
<Icon size="sm" rotate={dir === 'rtl' ? 180 : 0}>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
import MetricsTable from './MetricsTable';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export function OSTable({ websiteId, ...props }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { basePath } = useRouter();
|
||||
|
||||
function renderLink({ x: os }) {
|
||||
return (
|
||||
<FilterLink id="os" value={os}>
|
||||
<img
|
||||
src={`${basePath}/images/os/${
|
||||
src={`${process.env.basePath}/images/os/${
|
||||
os?.toLowerCase().replaceAll(/[^\w]+/g, '-') || 'unknown'
|
||||
}.png`}
|
||||
alt={os}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import { emptyFilter } from 'lib/filters';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
|
|
@ -11,7 +10,6 @@ export function RegionsTable({ websiteId, ...props }) {
|
|||
const { locale } = useLocale();
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const countryNames = useCountryNames(locale);
|
||||
const { basePath } = useRouter();
|
||||
|
||||
const renderLabel = (code, country) => {
|
||||
const region = code.includes('-') ? code : `${country}-${code}`;
|
||||
|
|
@ -21,7 +19,10 @@ export function RegionsTable({ websiteId, ...props }) {
|
|||
const renderLink = ({ x: code, country }) => {
|
||||
return (
|
||||
<FilterLink id="region" className={locale} value={code} label={renderLabel(code, country)}>
|
||||
<img src={`${basePath}/images/flags/${country?.toLowerCase() || 'xx'}.png`} alt={code} />
|
||||
<img
|
||||
src={`${process.env.basePath}/images/flags/${country?.toLowerCase() || 'xx'}.png`}
|
||||
alt={code}
|
||||
/>
|
||||
</FilterLink>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,157 +0,0 @@
|
|||
import WebsiteSelect from 'components/input/WebsiteSelect';
|
||||
import Page from 'components/layout/Page';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import EventsChart from 'components/metrics/EventsChart';
|
||||
import WebsiteChart from 'components/pages/websites/WebsiteChart';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import Script from 'next/script';
|
||||
import { Button, Column, Row } from 'react-basics';
|
||||
import styles from './TestConsole.module.css';
|
||||
|
||||
export function TestConsole() {
|
||||
const { get, useQuery } = useApi();
|
||||
const { data, isLoading, error } = useQuery(['websites:me'], () => get('/me/websites'));
|
||||
const router = useRouter();
|
||||
const {
|
||||
basePath,
|
||||
query: { id },
|
||||
} = router;
|
||||
|
||||
function handleChange(value) {
|
||||
router.push(`/console/${value}`);
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
window.umami.track({ url: '/page-view', referrer: 'https://www.google.com' });
|
||||
window.umami.track('track-event-no-data');
|
||||
window.umami.track('track-event-with-data', {
|
||||
test: 'test-data',
|
||||
boolean: true,
|
||||
booleanError: 'true',
|
||||
time: new Date(),
|
||||
number: 1,
|
||||
number2: Math.random() * 100,
|
||||
time2: new Date().toISOString(),
|
||||
nested: {
|
||||
test: 'test-data',
|
||||
number: 1,
|
||||
object: {
|
||||
test: 'test-data',
|
||||
},
|
||||
},
|
||||
array: [1, 2, 3],
|
||||
});
|
||||
}
|
||||
|
||||
function handleIdentifyClick() {
|
||||
window.umami.identify({
|
||||
userId: 123,
|
||||
name: 'brian',
|
||||
number: Math.random() * 100,
|
||||
test: 'test-data',
|
||||
boolean: true,
|
||||
booleanError: 'true',
|
||||
time: new Date(),
|
||||
time2: new Date().toISOString(),
|
||||
nested: {
|
||||
test: 'test-data',
|
||||
number: 1,
|
||||
object: {
|
||||
test: 'test-data',
|
||||
},
|
||||
},
|
||||
array: [1, 2, 3],
|
||||
});
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [websiteId] = id || [];
|
||||
const website = data?.data.find(({ id }) => websiteId === id);
|
||||
|
||||
return (
|
||||
<Page loading={isLoading} error={error}>
|
||||
<Head>
|
||||
<title>{website ? `${website.name} | Umami Console` : 'Umami Console'}</title>
|
||||
</Head>
|
||||
<PageHeader title="Test console">
|
||||
<WebsiteSelect websiteId={website?.id} onSelect={handleChange} />
|
||||
</PageHeader>
|
||||
{website && (
|
||||
<>
|
||||
<Script
|
||||
async
|
||||
data-website-id={website.id}
|
||||
src={`${basePath}/script.js`}
|
||||
data-cache="true"
|
||||
/>
|
||||
<Row className={styles.test}>
|
||||
<Column xs="4">
|
||||
<div className={styles.header}>Page links</div>
|
||||
<div>
|
||||
<Link href={`/console/${websiteId}/page/1/?q=abc`}>page one</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Link href={`/console/${websiteId}/page/2/?q=123 `}>page two</Link>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://www.google.com" data-umami-event="external-link-direct">
|
||||
external link (direct)
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://www.google.com"
|
||||
data-umami-event="external-link-tab"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
external link (tab)
|
||||
</a>
|
||||
</div>
|
||||
</Column>
|
||||
<Column xs="4">
|
||||
<div className={styles.header}>Click events</div>
|
||||
<Button id="send-event-button" data-umami-event="button-click" variant="action">
|
||||
Send event
|
||||
</Button>
|
||||
<p />
|
||||
<Button
|
||||
id="send-event-data-button"
|
||||
data-umami-event="button-click"
|
||||
data-umami-event-name="bob"
|
||||
data-umami-event-id="123"
|
||||
variant="action"
|
||||
>
|
||||
Send event with data
|
||||
</Button>
|
||||
</Column>
|
||||
<Column xs="4">
|
||||
<div className={styles.header}>Javascript events</div>
|
||||
<Button id="manual-button" variant="action" onClick={handleClick}>
|
||||
Run script
|
||||
</Button>
|
||||
<p />
|
||||
<Button id="manual-button" variant="action" onClick={handleIdentifyClick}>
|
||||
Run identify
|
||||
</Button>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row>
|
||||
<Column>
|
||||
<WebsiteChart websiteId={website.id} />
|
||||
<EventsChart websiteId={website.id} />
|
||||
</Column>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestConsole;
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
.test {
|
||||
border: 1px solid var(--base400);
|
||||
border-radius: 5px;
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { Button, Icon, Icons, Text } from 'react-basics';
|
||||
import Link from 'next/link';
|
||||
import Page from 'components/layout/Page';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import Pager from 'components/common/Pager';
|
||||
import WebsiteChartList from 'components/pages/websites/WebsiteChartList';
|
||||
import DashboardSettingsButton from 'components/pages/dashboard/DashboardSettingsButton';
|
||||
import DashboardEdit from 'components/pages/dashboard/DashboardEdit';
|
||||
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import useDashboard from 'store/dashboard';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
import useApiFilter from 'components/hooks/useApiFilter';
|
||||
|
||||
export function Dashboard() {
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { showCharts, editing } = useDashboard();
|
||||
const { dir } = useLocale();
|
||||
const { get, useQuery } = useApi();
|
||||
const { page, handlePageChange } = useApiFilter();
|
||||
const pageSize = 10;
|
||||
const {
|
||||
data: result,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery(['websites', page, pageSize], () =>
|
||||
get('/websites', { includeTeams: 1, page, pageSize }),
|
||||
);
|
||||
const { data, count } = result || {};
|
||||
const hasData = data && data?.length !== 0;
|
||||
|
||||
return (
|
||||
<Page loading={isLoading} error={error}>
|
||||
<PageHeader title={formatMessage(labels.dashboard)}>
|
||||
{!editing && hasData && <DashboardSettingsButton />}
|
||||
</PageHeader>
|
||||
{!hasData && (
|
||||
<EmptyPlaceholder message={formatMessage(messages.noWebsitesConfigured)}>
|
||||
<Link href="/settings/websites">
|
||||
<Button>
|
||||
<Icon rotate={dir === 'rtl' ? 180 : 0}>
|
||||
<Icons.ArrowRight />
|
||||
</Icon>
|
||||
<Text>{formatMessage(messages.goToSettings)}</Text>
|
||||
</Button>
|
||||
</Link>
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
{hasData && (
|
||||
<>
|
||||
{editing && <DashboardEdit />}
|
||||
{!editing && (
|
||||
<>
|
||||
<WebsiteChartList websites={data} showCharts={showCharts} limit={pageSize} />
|
||||
<Pager
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
count={count}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
|
||||
import classNames from 'classnames';
|
||||
import { Button } from 'react-basics';
|
||||
import { firstBy } from 'thenby';
|
||||
import useDashboard, { saveDashboard } from 'store/dashboard';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import styles from './DashboardEdit.module.css';
|
||||
import Page from 'components/layout/Page';
|
||||
|
||||
const dragId = 'dashboard-website-ordering';
|
||||
|
||||
export function DashboardEdit() {
|
||||
const settings = useDashboard();
|
||||
const { websiteOrder } = settings;
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [order, setOrder] = useState(websiteOrder || []);
|
||||
const { get, useQuery } = useApi();
|
||||
const {
|
||||
data: result,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery(['websites'], () => get('/websites', { includeTeams: 1 }));
|
||||
const { data: websites } = result || {};
|
||||
|
||||
const ordered = useMemo(() => {
|
||||
if (websites) {
|
||||
return websites
|
||||
.map(website => ({ ...website, order: order.indexOf(website.id) }))
|
||||
.sort(firstBy('order'));
|
||||
}
|
||||
return [];
|
||||
}, [websites, order]);
|
||||
|
||||
function handleWebsiteDrag({ destination, source }) {
|
||||
if (!destination || destination.index === source.index) return;
|
||||
|
||||
const orderedWebsites = [...ordered];
|
||||
const [removed] = orderedWebsites.splice(source.index, 1);
|
||||
orderedWebsites.splice(destination.index, 0, removed);
|
||||
|
||||
setOrder(orderedWebsites.map(website => website?.id || 0));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
saveDashboard({
|
||||
editing: false,
|
||||
websiteOrder: order,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
saveDashboard({ editing: false, websiteOrder });
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setOrder([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Page loading={isLoading} error={error}>
|
||||
<div className={styles.buttons}>
|
||||
<Button onClick={handleSave} variant="action" size="small">
|
||||
{formatMessage(labels.save)}
|
||||
</Button>
|
||||
<Button onClick={handleCancel} size="small">
|
||||
{formatMessage(labels.cancel)}
|
||||
</Button>
|
||||
<Button onClick={handleReset} size="small">
|
||||
{formatMessage(labels.reset)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.dragActive}>
|
||||
<DragDropContext onDragEnd={handleWebsiteDrag}>
|
||||
<Droppable droppableId={dragId}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
style={{ marginBottom: snapshot.isDraggingOver ? 260 : null }}
|
||||
>
|
||||
{ordered.map(({ id, name, domain }, index) => (
|
||||
<Draggable key={id} draggableId={`${dragId}-${id}`} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
className={classNames(styles.item, {
|
||||
[styles.active]: snapshot.isDragging,
|
||||
})}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<div className={styles.text}>
|
||||
<h1>{name}</h1>
|
||||
<h2>{domain}</h2>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardEdit;
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.item h1 {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.item h2 {
|
||||
font-size: 14px;
|
||||
color: var(--base700);
|
||||
}
|
||||
|
||||
.text {
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--base400);
|
||||
background: var(--base50);
|
||||
}
|
||||
|
||||
.active .text {
|
||||
border-color: var(--base600);
|
||||
box-shadow: 4px 4px 4px var(--base100);
|
||||
}
|
||||
|
||||
.dragActive {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.dragActive:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { TooltipPopup, Icon, Text, Flexbox, Button } from 'react-basics';
|
||||
import Icons from 'components/icons';
|
||||
import { saveDashboard } from 'store/dashboard';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
|
||||
export function DashboardSettingsButton() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const handleToggleCharts = () => {
|
||||
saveDashboard(state => ({ showCharts: !state.showCharts }));
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
saveDashboard({ editing: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox gap={10}>
|
||||
<TooltipPopup label={formatMessage(labels.toggleCharts)} position="bottom">
|
||||
<Button onClick={handleToggleCharts}>
|
||||
<Icon>
|
||||
<Icons.BarChart />
|
||||
</Icon>
|
||||
</Button>
|
||||
</TooltipPopup>
|
||||
<Button onClick={handleEdit}>
|
||||
<Icon>
|
||||
<Icons.Edit />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.edit)}</Text>
|
||||
</Button>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardSettingsButton;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
.buttonGroup {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import { Column, Row } from 'react-basics';
|
||||
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 (
|
||||
<Row className={styles.row}>
|
||||
<Column defaultSize={12} xl={8}>
|
||||
<MetricsBar isLoading={isLoading} isFetched={isFetched} error={error}>
|
||||
{!error && isFetched && (
|
||||
<>
|
||||
<MetricCard
|
||||
className={styles.card}
|
||||
label={formatMessage(labels.events)}
|
||||
value={data?.events}
|
||||
/>
|
||||
<MetricCard
|
||||
className={styles.card}
|
||||
label={formatMessage(labels.fields)}
|
||||
value={data?.fields}
|
||||
/>
|
||||
<MetricCard
|
||||
className={styles.card}
|
||||
label={formatMessage(labels.totalRecords)}
|
||||
value={data?.records}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</MetricsBar>
|
||||
</Column>
|
||||
<Column defaultSize={12} xl={4}>
|
||||
<div className={styles.actions}>
|
||||
<WebsiteDateFilter websiteId={websiteId} />
|
||||
</div>
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventDataMetricsBar;
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
.container {
|
||||
display: flex;
|
||||
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);
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
min-height: 110px;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.card {
|
||||
flex-basis: calc(50% - 20px);
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import Link from 'next/link';
|
||||
import { GridTable, GridColumn } from 'react-basics';
|
||||
import { useMessages, usePageQuery } from 'components/hooks';
|
||||
import Empty from 'components/common/Empty';
|
||||
import { DATA_TYPES } from 'lib/constants';
|
||||
|
||||
export function EventDataTable({ data = [] }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { resolveUrl } = usePageQuery();
|
||||
|
||||
if (data.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GridTable data={data}>
|
||||
<GridColumn name="eventName" label={formatMessage(labels.event)}>
|
||||
{row => (
|
||||
<Link href={resolveUrl({ event: row.eventName })} shallow={true}>
|
||||
{row.eventName}
|
||||
</Link>
|
||||
)}
|
||||
</GridColumn>
|
||||
<GridColumn name="fieldName" label={formatMessage(labels.field)}>
|
||||
{row => row.fieldName}
|
||||
</GridColumn>
|
||||
<GridColumn name="dataType" label={formatMessage(labels.type)}>
|
||||
{row => DATA_TYPES[row.dataType]}
|
||||
</GridColumn>
|
||||
<GridColumn name="total" label={formatMessage(labels.totalRecords)}>
|
||||
{({ total }) => total.toLocaleString()}
|
||||
</GridColumn>
|
||||
</GridTable>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventDataTable;
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import { GridTable, GridColumn, Button, Icon, Text } from 'react-basics';
|
||||
import { useMessages, usePageQuery } 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 { resolveUrl } = usePageQuery();
|
||||
|
||||
const Title = () => {
|
||||
return (
|
||||
<>
|
||||
<Link href={resolveUrl({ 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;
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
Form,
|
||||
FormRow,
|
||||
FormInput,
|
||||
FormButtons,
|
||||
TextField,
|
||||
PasswordField,
|
||||
SubmitButton,
|
||||
Icon,
|
||||
} from 'react-basics';
|
||||
import { useRouter } from 'next/router';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import { setUser } from 'store/app';
|
||||
import { setClientAuthToken } from 'lib/client';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import Logo from 'assets/logo.svg';
|
||||
import styles from './LoginForm.module.css';
|
||||
|
||||
export function LoginForm() {
|
||||
const { formatMessage, labels, getMessage } = useMessages();
|
||||
const router = useRouter();
|
||||
const { post } = useApi();
|
||||
const { mutate, error, isLoading } = useMutation(data => post('/auth/login', data));
|
||||
|
||||
const handleSubmit = async data => {
|
||||
mutate(data, {
|
||||
onSuccess: async ({ token, user }) => {
|
||||
setClientAuthToken(token);
|
||||
setUser(user);
|
||||
|
||||
await router.push('/dashboard');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.login}>
|
||||
<Icon className={styles.icon} size="xl">
|
||||
<Logo />
|
||||
</Icon>
|
||||
<div className={styles.title}>umami</div>
|
||||
<Form className={styles.form} onSubmit={handleSubmit} error={getMessage(error)}>
|
||||
<FormRow label={formatMessage(labels.username)}>
|
||||
<FormInput name="username" rules={{ required: formatMessage(labels.required) }}>
|
||||
<TextField autoComplete="off" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.password)}>
|
||||
<FormInput name="password" rules={{ required: formatMessage(labels.required) }}>
|
||||
<PasswordField />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<SubmitButton className={styles.button} variant="primary" disabled={isLoading}>
|
||||
{formatMessage(labels.login)}
|
||||
</SubmitButton>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginForm;
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
.login {
|
||||
width: 400px;
|
||||
margin: auto;
|
||||
transform: translateY(-25%);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 auto;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.button {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import Head from 'next/head';
|
||||
import useLocale from 'components/hooks/useLocale';
|
||||
import styles from './LoginLayout.module.css';
|
||||
|
||||
export function LoginLayout({ children }) {
|
||||
const { dir } = useLocale();
|
||||
|
||||
return (
|
||||
<div className={styles.layout} dir={dir}>
|
||||
<Head>
|
||||
<title>{`Login | umami`}</title>
|
||||
</Head>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginLayout;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background: var(--base75);
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
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 { basePath } = useRouter();
|
||||
|
||||
const renderCountryName = useCallback(
|
||||
({ x: code }) => (
|
||||
<span className={classNames(locale, styles.row)}>
|
||||
<img src={`${basePath}/images/flags/${code?.toLowerCase() || 'xx'}.png`} alt={code} />
|
||||
{countryNames[code]}
|
||||
</span>
|
||||
),
|
||||
[countryNames, locale, basePath],
|
||||
);
|
||||
|
||||
return (
|
||||
<ListTable
|
||||
title={formatMessage(labels.countries)}
|
||||
metric={formatMessage(labels.visitors)}
|
||||
data={data}
|
||||
renderLabel={renderCountryName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default RealtimeCountries;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
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;
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
.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;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.row .link {
|
||||
color: var(--base900);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.row .link:hover {
|
||||
color: var(--primary400);
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { subMinutes, startOfMinute } from 'date-fns';
|
||||
import firstBy from 'thenby';
|
||||
import { GridRow, GridColumn } 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 'components/pages/realtime/RealtimeLog';
|
||||
import RealtimeHeader from 'components/pages/realtime/RealtimeHeader';
|
||||
import RealtimeUrls from 'components/pages/realtime/RealtimeUrls';
|
||||
import RealtimeCountries from 'components/pages/realtime/RealtimeCountries';
|
||||
import WebsiteHeader from 'components/pages/websites/WebsiteHeader';
|
||||
import useApi from 'components/hooks/useApi';
|
||||
import { percentFilter } from 'lib/filters';
|
||||
import { REALTIME_RANGE, REALTIME_INTERVAL } from 'lib/constants';
|
||||
import styles from './RealtimePage.module.css';
|
||||
import { useWebsite } from 'components/hooks';
|
||||
|
||||
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 RealtimePage({ 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]);
|
||||
|
||||
return (
|
||||
<Page loading={isLoading} error={error}>
|
||||
<WebsiteHeader websiteId={websiteId} />
|
||||
<RealtimeHeader websiteId={websiteId} data={currentData} />
|
||||
<RealtimeChart className={styles.chart} data={realtimeData} unit="minute" />
|
||||
<GridRow>
|
||||
<GridColumn xs={12} sm={12} md={12} lg={4} xl={4}>
|
||||
<RealtimeUrls websiteId={websiteId} websiteDomain={website?.domain} data={realtimeData} />
|
||||
</GridColumn>
|
||||
<GridColumn xs={12} sm={12} md={12} lg={8} xl={8}>
|
||||
<RealtimeLog websiteId={websiteId} websiteDomain={website?.domain} data={realtimeData} />
|
||||
</GridColumn>
|
||||
</GridRow>
|
||||
<GridRow>
|
||||
<GridColumn xs={12} lg={4}>
|
||||
<RealtimeCountries data={realtimeData?.countries} />
|
||||
</GridColumn>
|
||||
<GridColumn xs={12} lg={8}>
|
||||
<WorldMap data={realtimeData?.countries} />
|
||||
</GridColumn>
|
||||
</GridRow>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default RealtimePage;
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import { FormRow } from 'react-basics';
|
||||
import DateFilter from 'components/input/DateFilter';
|
||||
import WebsiteSelect from 'components/input/WebsiteSelect';
|
||||
import { parseDateRange } from 'lib/date';
|
||||
import { useContext } from 'react';
|
||||
import { ReportContext } from './Report';
|
||||
import { useMessages } from 'components/hooks';
|
||||
|
||||
export function BaseParameters({
|
||||
showWebsiteSelect = true,
|
||||
allowWebsiteSelect = true,
|
||||
showDateSelect = true,
|
||||
allowDateSelect = true,
|
||||
}) {
|
||||
const { report, updateReport } = useContext(ReportContext);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const { parameters } = report || {};
|
||||
const { websiteId, dateRange } = parameters || {};
|
||||
const { value, startDate, endDate } = dateRange || {};
|
||||
|
||||
const handleWebsiteSelect = websiteId => {
|
||||
updateReport({ websiteId, parameters: { websiteId } });
|
||||
};
|
||||
|
||||
const handleDateChange = value => {
|
||||
updateReport({ parameters: { dateRange: { ...parseDateRange(value) } } });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showWebsiteSelect && (
|
||||
<FormRow label={formatMessage(labels.website)}>
|
||||
{allowWebsiteSelect && (
|
||||
<WebsiteSelect websiteId={websiteId} onSelect={handleWebsiteSelect} />
|
||||
)}
|
||||
</FormRow>
|
||||
)}
|
||||
{showDateSelect && (
|
||||
<FormRow label={formatMessage(labels.dateRange)}>
|
||||
{allowDateSelect && (
|
||||
<DateFilter
|
||||
value={value}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
)}
|
||||
</FormRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BaseParameters;
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { REPORT_PARAMETERS } from 'lib/constants';
|
||||
import PopupForm from './PopupForm';
|
||||
import FieldSelectForm from './FieldSelectForm';
|
||||
import FieldAggregateForm from './FieldAggregateForm';
|
||||
import FieldFilterForm from './FieldFilterForm';
|
||||
import styles from './FieldAddForm.module.css';
|
||||
|
||||
export function FieldAddForm({ fields = [], group, element, onAdd, onClose }) {
|
||||
const [selected, setSelected] = useState();
|
||||
|
||||
const handleSelect = value => {
|
||||
const { type } = value;
|
||||
|
||||
if (group === REPORT_PARAMETERS.groups || type === 'array' || type === 'boolean') {
|
||||
value.value = group === REPORT_PARAMETERS.groups ? '' : 'total';
|
||||
handleSave(value);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelected(value);
|
||||
};
|
||||
|
||||
const handleSave = value => {
|
||||
onAdd(group, value);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<PopupForm className={styles.popup} element={element} onClose={onClose}>
|
||||
{!selected && <FieldSelectForm fields={fields} onSelect={handleSelect} />}
|
||||
{selected && group === REPORT_PARAMETERS.fields && (
|
||||
<FieldAggregateForm {...selected} onSelect={handleSave} />
|
||||
)}
|
||||
{selected && group === REPORT_PARAMETERS.filters && (
|
||||
<FieldFilterForm {...selected} onSelect={handleSave} />
|
||||
)}
|
||||
</PopupForm>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldAddForm;
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
.menu {
|
||||
width: 360px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--base75);
|
||||
}
|
||||
|
||||
.type {
|
||||
color: var(--font-color300);
|
||||
}
|
||||
|
||||
.selected {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.popup {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { Form, FormRow, Menu, Item } from 'react-basics';
|
||||
import { useMessages } from 'components/hooks';
|
||||
|
||||
export default function FieldAggregateForm({ name, type, onSelect }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const options = {
|
||||
number: [
|
||||
{ label: formatMessage(labels.sum), value: 'sum' },
|
||||
{ label: formatMessage(labels.average), value: 'average' },
|
||||
{ label: formatMessage(labels.min), value: 'min' },
|
||||
{ label: formatMessage(labels.max), value: 'max' },
|
||||
],
|
||||
date: [
|
||||
{ label: formatMessage(labels.min), value: 'min' },
|
||||
{ label: formatMessage(labels.max), value: 'max' },
|
||||
],
|
||||
string: [
|
||||
{ label: formatMessage(labels.total), value: 'total' },
|
||||
{ label: formatMessage(labels.unique), value: 'unique' },
|
||||
],
|
||||
uuid: [
|
||||
{ label: formatMessage(labels.total), value: 'total' },
|
||||
{ label: formatMessage(labels.unique), value: 'unique' },
|
||||
],
|
||||
};
|
||||
|
||||
const items = options[type];
|
||||
|
||||
const handleSelect = value => {
|
||||
onSelect({ name, type, value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<FormRow label={name}>
|
||||
<Menu onSelect={handleSelect}>
|
||||
{items.map(({ label, value }) => {
|
||||
return <Item key={value}>{label}</Item>;
|
||||
})}
|
||||
</Menu>
|
||||
</FormRow>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Form, FormRow, Item, Flexbox, Dropdown, Button } from 'react-basics';
|
||||
import { useMessages, useFilters, useFormat } from 'components/hooks';
|
||||
import styles from './FieldFilterForm.module.css';
|
||||
|
||||
export default function FieldFilterForm({
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
values,
|
||||
onSelect,
|
||||
allowFilterSelect = true,
|
||||
}) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const [filter, setFilter] = useState('eq');
|
||||
const [value, setValue] = useState();
|
||||
const { getFilters } = useFilters();
|
||||
const { formatValue } = useFormat();
|
||||
const filters = getFilters(type);
|
||||
|
||||
const renderFilterValue = value => {
|
||||
return filters.find(f => f.value === value)?.label;
|
||||
};
|
||||
|
||||
const renderValue = value => {
|
||||
return formatValue(value, name);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
onSelect({ name, type, filter, value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<FormRow label={label} className={styles.filter}>
|
||||
<Flexbox gap={10}>
|
||||
{allowFilterSelect && (
|
||||
<Dropdown
|
||||
className={styles.dropdown}
|
||||
items={filters}
|
||||
value={filter}
|
||||
renderValue={renderFilterValue}
|
||||
onChange={setFilter}
|
||||
>
|
||||
{({ value, label }) => {
|
||||
return <Item key={value}>{label}</Item>;
|
||||
}}
|
||||
</Dropdown>
|
||||
)}
|
||||
<Dropdown
|
||||
className={styles.dropdown}
|
||||
menuProps={{ className: styles.menu }}
|
||||
items={values}
|
||||
value={value}
|
||||
renderValue={renderValue}
|
||||
onChange={setValue}
|
||||
style={{
|
||||
minWidth: '250px',
|
||||
}}
|
||||
>
|
||||
{value => {
|
||||
return <Item key={value}>{formatValue(value, name)}</Item>;
|
||||
}}
|
||||
</Dropdown>
|
||||
</Flexbox>
|
||||
<Button variant="primary" onClick={handleAdd} disabled={!filter || !value}>
|
||||
{formatMessage(labels.add)}
|
||||
</Button>
|
||||
</FormRow>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
.selected {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.popup {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
min-width: 360px;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { Menu, Item, Form, FormRow } from 'react-basics';
|
||||
import { useMessages } from 'components/hooks';
|
||||
import styles from './FieldSelectForm.module.css';
|
||||
|
||||
export default function FieldSelectForm({ items, onSelect, showType = true }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<FormRow label={formatMessage(labels.fields)}>
|
||||
<Menu className={styles.menu} onSelect={key => onSelect(items[key])}>
|
||||
{items.map(({ name, label, type }, index) => {
|
||||
return (
|
||||
<Item key={index} className={styles.item}>
|
||||
<div>{label || name}</div>
|
||||
{showType && type && <div className={styles.type}>{type}</div>}
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
</FormRow>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
.menu {
|
||||
width: 360px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--base75);
|
||||
}
|
||||
|
||||
.type {
|
||||
color: var(--font-color300);
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import FieldSelectForm from './FieldSelectForm';
|
||||
import FieldFilterForm from './FieldFilterForm';
|
||||
import { useApi } from 'components/hooks';
|
||||
import { Loading } from 'react-basics';
|
||||
|
||||
function useValues(websiteId, type) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { data, error, isLoading } = useQuery(
|
||||
['websites:values', websiteId, type],
|
||||
() =>
|
||||
get(`/websites/${websiteId}/values`, {
|
||||
type,
|
||||
}),
|
||||
{ enabled: !!(websiteId && type) },
|
||||
);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export default function FilterSelectForm({ websiteId, items, onSelect, allowFilterSelect }) {
|
||||
const [field, setField] = useState();
|
||||
const { data, isLoading } = useValues(websiteId, field?.name);
|
||||
|
||||
if (!field) {
|
||||
return <FieldSelectForm items={items} onSelect={setField} showType={false} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading position="center" icon="dots" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldFilterForm
|
||||
name={field?.name}
|
||||
label={field?.label}
|
||||
type={field?.type}
|
||||
values={data}
|
||||
onSelect={onSelect}
|
||||
allowFilterSelect={allowFilterSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { Icon, TooltipPopup } from 'react-basics';
|
||||
import Icons from 'components/icons';
|
||||
import Empty from 'components/common/Empty';
|
||||
import { useMessages } from 'components/hooks';
|
||||
import styles from './ParameterList.module.css';
|
||||
|
||||
export function ParameterList({ items = [], children, onRemove }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{!items.length && <Empty message={formatMessage(labels.none)} />}
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<div key={index} className={styles.item}>
|
||||
{typeof children === 'function' ? children(item) : item}
|
||||
<TooltipPopup
|
||||
className={styles.icon}
|
||||
label={formatMessage(labels.remove)}
|
||||
position="right"
|
||||
>
|
||||
<Icon onClick={onRemove.bind(null, index)}>
|
||||
<Icons.Close />
|
||||
</Icon>
|
||||
</TooltipPopup>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ParameterList;
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--base400);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 1px 1px 1px var(--base400);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
align-self: center;
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import styles from './PopupForm.module.css';
|
||||
|
||||
export function PopupForm({ className, style, children }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.form, className)}
|
||||
style={style}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PopupForm;
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
.form {
|
||||
position: absolute;
|
||||
background: var(--base50);
|
||||
min-width: 300px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--base400);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { createContext } from 'react';
|
||||
import Page from 'components/layout/Page';
|
||||
import styles from './reports.module.css';
|
||||
import { useReport } from 'components/hooks';
|
||||
|
||||
export const ReportContext = createContext(null);
|
||||
|
||||
export function Report({ reportId, defaultParameters, children, ...props }) {
|
||||
const report = useReport(reportId, defaultParameters);
|
||||
|
||||
//console.log({ report });
|
||||
|
||||
return (
|
||||
<ReportContext.Provider value={{ ...report }}>
|
||||
<Page {...props} className={styles.container}>
|
||||
{children}
|
||||
</Page>
|
||||
</ReportContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Report;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import styles from './reports.module.css';
|
||||
|
||||
export function ReportBody({ children }) {
|
||||
return <div className={styles.body}>{children}</div>;
|
||||
}
|
||||
|
||||
export default ReportBody;
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import FunnelReport from './funnel/FunnelReport';
|
||||
import EventDataReport from './event-data/EventDataReport';
|
||||
import InsightsReport from './insights/InsightsReport';
|
||||
import RetentionReport from './retention/RetentionReport';
|
||||
|
||||
const reports = {
|
||||
funnel: FunnelReport,
|
||||
'event-data': EventDataReport,
|
||||
insights: InsightsReport,
|
||||
retention: RetentionReport,
|
||||
};
|
||||
|
||||
export default function ReportDetails({ reportId, reportType }) {
|
||||
const Report = reports[reportType];
|
||||
|
||||
return <Report reportId={reportId} />;
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import { useContext } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Icon, LoadingButton, InlineEditField, useToasts } from 'react-basics';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import { useMessages, useApi } from 'components/hooks';
|
||||
import { ReportContext } from './Report';
|
||||
import styles from './ReportHeader.module.css';
|
||||
import reportStyles from './reports.module.css';
|
||||
|
||||
export function ReportHeader({ icon }) {
|
||||
const { report, updateReport } = useContext(ReportContext);
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const { showToast } = useToasts();
|
||||
const { post, useMutation } = useApi();
|
||||
const router = useRouter();
|
||||
const { mutate: create, isLoading: isCreating } = useMutation(data => post(`/reports`, data));
|
||||
const { mutate: update, isLoading: isUpdating } = useMutation(data =>
|
||||
post(`/reports/${data.id}`, data),
|
||||
);
|
||||
|
||||
const { name, description, parameters } = report || {};
|
||||
const { websiteId, dateRange } = parameters || {};
|
||||
const defaultName = formatMessage(labels.untitled);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!report.id) {
|
||||
create(report, {
|
||||
onSuccess: async ({ id }) => {
|
||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
||||
router.push(`/reports/${id}`, null, { shallow: true });
|
||||
},
|
||||
});
|
||||
} else {
|
||||
update(report, {
|
||||
onSuccess: async () => {
|
||||
showToast({ message: formatMessage(messages.saved), variant: 'success' });
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = name => {
|
||||
updateReport({ name: name || defaultName });
|
||||
};
|
||||
|
||||
const handleDescriptionChange = description => {
|
||||
updateReport({ description });
|
||||
};
|
||||
|
||||
const Title = () => {
|
||||
return (
|
||||
<>
|
||||
<Icon size="lg">{icon}</Icon>
|
||||
<InlineEditField
|
||||
key={name}
|
||||
name="name"
|
||||
value={name}
|
||||
placeholder={defaultName}
|
||||
onCommit={handleNameChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={reportStyles.header}>
|
||||
<PageHeader title={<Title />}>
|
||||
<LoadingButton
|
||||
variant="primary"
|
||||
isLoading={isCreating || isUpdating}
|
||||
disabled={!websiteId || !dateRange?.value || !name}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{formatMessage(labels.save)}
|
||||
</LoadingButton>
|
||||
</PageHeader>
|
||||
<div className={styles.description}>
|
||||
<InlineEditField
|
||||
key={description}
|
||||
name="description"
|
||||
value={description}
|
||||
placeholder={`+ ${formatMessage(labels.addDescription)}`}
|
||||
onCommit={handleDescriptionChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportHeader;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
.description {
|
||||
color: var(--font-color300);
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import styles from './reports.module.css';
|
||||
|
||||
export function ReportMenu({ children }) {
|
||||
return <div className={styles.menu}>{children}</div>;
|
||||
}
|
||||
|
||||
export default ReportMenu;
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import Link from 'next/link';
|
||||
import { Button, Icons, Text, Icon } from 'react-basics';
|
||||
import Page from 'components/layout/Page';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import Funnel from 'assets/funnel.svg';
|
||||
import Lightbulb from 'assets/lightbulb.svg';
|
||||
import Magnet from 'assets/magnet.svg';
|
||||
import styles from './ReportTemplates.module.css';
|
||||
import { useMessages } from 'components/hooks';
|
||||
|
||||
function ReportItem({ title, description, url, icon }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<div className={styles.report}>
|
||||
<div className={styles.title}>
|
||||
<Icon size="lg">{icon}</Icon>
|
||||
<Text>{title}</Text>
|
||||
</div>
|
||||
<div className={styles.description}>{description}</div>
|
||||
<div className={styles.buttons}>
|
||||
<Link href={url}>
|
||||
<Button variant="primary">
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.create)}</Text>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportTemplates({ showHeader = true }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const reports = [
|
||||
{
|
||||
title: formatMessage(labels.insights),
|
||||
description: formatMessage(labels.insightsDescription),
|
||||
url: '/reports/insights',
|
||||
icon: <Lightbulb />,
|
||||
},
|
||||
{
|
||||
title: formatMessage(labels.funnel),
|
||||
description: formatMessage(labels.funnelDescription),
|
||||
url: '/reports/funnel',
|
||||
icon: <Funnel />,
|
||||
},
|
||||
{
|
||||
title: formatMessage(labels.retention),
|
||||
description: formatMessage(labels.retentionDescription),
|
||||
url: '/reports/retention',
|
||||
icon: <Magnet />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Page>
|
||||
{showHeader && <PageHeader title={formatMessage(labels.reports)} />}
|
||||
<div className={styles.reports}>
|
||||
{reports.map(({ title, description, url, icon }) => {
|
||||
return (
|
||||
<ReportItem key={title} icon={icon} title={title} description={description} url={url} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportTemplates;
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
.reports {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.report {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--base500);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.description {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import PageHeader from 'components/layout/PageHeader';
|
||||
import { useMessages, useApi } from 'components/hooks';
|
||||
import Link from 'next/link';
|
||||
import { Button, Icon, Icons, Text } from 'react-basics';
|
||||
import ReportsTable from './ReportsTable';
|
||||
import useFilterQuery from 'components/hooks/useFilterQuery';
|
||||
import DataTable from 'components/common/DataTable';
|
||||
|
||||
function useReports() {
|
||||
const { get, del, useMutation } = useApi();
|
||||
const { mutate } = useMutation(reportId => del(`/reports/${reportId}`));
|
||||
const reports = useFilterQuery(['reports'], params => get(`/reports`, params));
|
||||
|
||||
const deleteReport = id => {
|
||||
mutate(id, {
|
||||
onSuccess: () => {
|
||||
reports.refetch();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return { reports, deleteReport };
|
||||
}
|
||||
|
||||
export function ReportsPage() {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { reports, deleteReport } = useReports();
|
||||
|
||||
const handleDelete = async (id, callback) => {
|
||||
await deleteReport(id);
|
||||
await reports.refetch();
|
||||
callback?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={formatMessage(labels.reports)}>
|
||||
<Link href="/reports/create">
|
||||
<Button variant="primary">
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.createReport)}</Text>
|
||||
</Button>
|
||||
</Link>
|
||||
</PageHeader>
|
||||
<DataTable {...reports}>
|
||||
{({ data }) => <ReportsTable data={data} showDomain={true} onDelete={handleDelete} />}
|
||||
</DataTable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportsPage;
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import ConfirmDeleteForm from 'components/common/ConfirmDeleteForm';
|
||||
import LinkButton from 'components/common/LinkButton';
|
||||
import { useMessages } from 'components/hooks';
|
||||
import useUser from 'components/hooks/useUser';
|
||||
import {
|
||||
Button,
|
||||
Flexbox,
|
||||
GridColumn,
|
||||
GridTable,
|
||||
Icon,
|
||||
Icons,
|
||||
Modal,
|
||||
ModalTrigger,
|
||||
Text,
|
||||
} from 'react-basics';
|
||||
import { REPORT_TYPES } from 'lib/constants';
|
||||
|
||||
export function ReportsTable({ data = [], onDelete, showDomain }) {
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { user } = useUser();
|
||||
|
||||
const handleConfirm = (id, callback) => {
|
||||
onDelete?.(id, callback);
|
||||
};
|
||||
|
||||
return (
|
||||
<GridTable data={data}>
|
||||
<GridColumn name="name" label={formatMessage(labels.name)} />
|
||||
<GridColumn name="description" label={formatMessage(labels.description)} />
|
||||
<GridColumn name="type" label={formatMessage(labels.type)}>
|
||||
{row => {
|
||||
return formatMessage(
|
||||
labels[Object.keys(REPORT_TYPES).find(key => REPORT_TYPES[key] === row.type)],
|
||||
);
|
||||
}}
|
||||
</GridColumn>
|
||||
{showDomain && (
|
||||
<GridColumn name="domain" label={formatMessage(labels.domain)}>
|
||||
{row => row.website.domain}
|
||||
</GridColumn>
|
||||
)}
|
||||
<GridColumn name="action" label="" alignment="end">
|
||||
{row => {
|
||||
const { id, name, userId, website } = row;
|
||||
return (
|
||||
<Flexbox gap={10}>
|
||||
<LinkButton href={`/reports/${id}`}>{formatMessage(labels.view)}</LinkButton>
|
||||
{(user.id === userId || user.id === website?.userId) && (
|
||||
<ModalTrigger>
|
||||
<Button>
|
||||
<Icon>
|
||||
<Icons.Trash />
|
||||
</Icon>
|
||||
<Text>{formatMessage(labels.delete)}</Text>
|
||||
</Button>
|
||||
<Modal>
|
||||
{close => (
|
||||
<ConfirmDeleteForm
|
||||
name={name}
|
||||
onConfirm={handleConfirm.bind(null, id, close)}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</ModalTrigger>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
}}
|
||||
</GridColumn>
|
||||
</GridTable>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReportsTable;
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
import { useContext, useRef } from 'react';
|
||||
import { useApi, useMessages } from 'components/hooks';
|
||||
import { Form, FormRow, FormButtons, SubmitButton, PopupTrigger, Icon, Popup } from 'react-basics';
|
||||
import { ReportContext } from 'components/pages/reports/Report';
|
||||
import Empty from 'components/common/Empty';
|
||||
import { DATA_TYPES, REPORT_PARAMETERS } from 'lib/constants';
|
||||
import Icons from 'components/icons';
|
||||
import FieldAddForm from '../FieldAddForm';
|
||||
import BaseParameters from '../BaseParameters';
|
||||
import ParameterList from '../ParameterList';
|
||||
import styles from './EventDataParameters.module.css';
|
||||
|
||||
function useFields(websiteId, startDate, endDate) {
|
||||
const { get, useQuery } = useApi();
|
||||
const { data, error, isLoading } = useQuery(
|
||||
['fields', websiteId, startDate, endDate],
|
||||
() =>
|
||||
get('/reports/event-data', {
|
||||
websiteId,
|
||||
startAt: +startDate,
|
||||
endAt: +endDate,
|
||||
}),
|
||||
{ enabled: !!(websiteId && startDate && endDate) },
|
||||
);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function EventDataParameters() {
|
||||
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
|
||||
const { formatMessage, labels, messages } = useMessages();
|
||||
const ref = useRef(null);
|
||||
const { parameters } = report || {};
|
||||
const { websiteId, dateRange, fields, filters, groups } = parameters || {};
|
||||
const { startDate, endDate } = dateRange || {};
|
||||
const queryEnabled = websiteId && dateRange && fields?.length;
|
||||
const { data, error } = useFields(websiteId, startDate, endDate);
|
||||
const parametersSelected = websiteId && startDate && endDate;
|
||||
const hasData = data?.length !== 0;
|
||||
|
||||
const parameterGroups = [
|
||||
{ label: formatMessage(labels.fields), group: REPORT_PARAMETERS.fields },
|
||||
{ label: formatMessage(labels.filters), group: REPORT_PARAMETERS.filters },
|
||||
];
|
||||
|
||||
const parameterData = {
|
||||
fields,
|
||||
filters,
|
||||
groups,
|
||||
};
|
||||
|
||||
const handleSubmit = values => {
|
||||
runReport(values);
|
||||
};
|
||||
|
||||
const handleAdd = (group, value) => {
|
||||
const data = parameterData[group];
|
||||
|
||||
if (!data.find(({ name }) => name === value.name)) {
|
||||
updateReport({ parameters: { [group]: data.concat(value) } });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (group, index) => {
|
||||
const data = [...parameterData[group]];
|
||||
data.splice(index, 1);
|
||||
updateReport({ parameters: { [group]: data } });
|
||||
};
|
||||
|
||||
const AddButton = ({ group }) => {
|
||||
return (
|
||||
<PopupTrigger>
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
<Popup position="bottom" alignment="start">
|
||||
{(close, element) => {
|
||||
return (
|
||||
<FieldAddForm
|
||||
fields={data.map(({ eventKey, eventDataType }) => ({
|
||||
name: eventKey,
|
||||
type: DATA_TYPES[eventDataType],
|
||||
}))}
|
||||
group={group}
|
||||
element={element}
|
||||
onAdd={handleAdd}
|
||||
onClose={close}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Popup>
|
||||
</PopupTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form ref={ref} values={parameters} error={error} onSubmit={handleSubmit}>
|
||||
<BaseParameters />
|
||||
{!hasData && <Empty message={formatMessage(messages.noEventData)} />}
|
||||
{parametersSelected &&
|
||||
hasData &&
|
||||
parameterGroups.map(({ label, group }) => {
|
||||
return (
|
||||
<FormRow
|
||||
key={label}
|
||||
label={label}
|
||||
action={<AddButton group={group} onAdd={handleAdd} />}
|
||||
>
|
||||
<ParameterList
|
||||
items={parameterData[group]}
|
||||
onRemove={index => handleRemove(group, index)}
|
||||
>
|
||||
{({ name, value }) => {
|
||||
return (
|
||||
<div className={styles.parameter}>
|
||||
{group === REPORT_PARAMETERS.fields && (
|
||||
<>
|
||||
<div>{name}</div>
|
||||
<div className={styles.op}>{value}</div>
|
||||
</>
|
||||
)}
|
||||
{group === REPORT_PARAMETERS.filters && (
|
||||
<>
|
||||
<div>{name}</div>
|
||||
<div className={styles.op}>{value[0]}</div>
|
||||
<div>{value[1]}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</ParameterList>
|
||||
</FormRow>
|
||||
);
|
||||
})}
|
||||
<FormButtons>
|
||||
<SubmitButton variant="primary" disabled={!queryEnabled} isLoading={isRunning}>
|
||||
{formatMessage(labels.runQuery)}
|
||||
</SubmitButton>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventDataParameters;
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
.parameter {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.op {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import Report from '../Report';
|
||||
import ReportHeader from '../ReportHeader';
|
||||
import ReportMenu from '../ReportMenu';
|
||||
import ReportBody from '../ReportBody';
|
||||
import EventDataParameters from './EventDataParameters';
|
||||
import EventDataTable from './EventDataTable';
|
||||
import Nodes from 'assets/nodes.svg';
|
||||
|
||||
const defaultParameters = {
|
||||
type: 'event-data',
|
||||
parameters: { fields: [], filters: [] },
|
||||
};
|
||||
|
||||
export default function EventDataReport({ reportId }) {
|
||||
return (
|
||||
<Report reportId={reportId} defaultParameters={defaultParameters}>
|
||||
<ReportHeader icon={<Nodes />} />
|
||||
<ReportMenu>
|
||||
<EventDataParameters />
|
||||
</ReportMenu>
|
||||
<ReportBody>
|
||||
<EventDataTable />
|
||||
</ReportBody>
|
||||
</Report>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { useContext } from 'react';
|
||||
import { GridTable, GridColumn } from 'react-basics';
|
||||
import { useMessages } from 'components/hooks';
|
||||
import { ReportContext } from '../Report';
|
||||
|
||||
export function EventDataTable() {
|
||||
const { report } = useContext(ReportContext);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
return (
|
||||
<GridTable data={report?.data || []}>
|
||||
<GridColumn name="field" label={formatMessage(labels.field)} />
|
||||
<GridColumn name="value" label={formatMessage(labels.value)} />
|
||||
<GridColumn name="total" label={formatMessage(labels.total)} />
|
||||
</GridTable>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventDataTable;
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import { useCallback, useContext, useMemo } from 'react';
|
||||
import { Loading, StatusLight } from 'react-basics';
|
||||
import useMessages from 'components/hooks/useMessages';
|
||||
import useTheme from 'components/hooks/useTheme';
|
||||
import BarChart from 'components/metrics/BarChart';
|
||||
import { formatLongNumber } from 'lib/format';
|
||||
import styles from './FunnelChart.module.css';
|
||||
import { ReportContext } from '../Report';
|
||||
|
||||
export function FunnelChart({ className, loading }) {
|
||||
const { report } = useContext(ReportContext);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const { colors } = useTheme();
|
||||
|
||||
const { parameters, data } = report || {};
|
||||
|
||||
const renderXLabel = useCallback(
|
||||
(label, index) => {
|
||||
return parameters.urls[index];
|
||||
},
|
||||
[parameters],
|
||||
);
|
||||
|
||||
const renderTooltipPopup = useCallback((setTooltipPopup, model) => {
|
||||
const { opacity, labelColors, dataPoints } = model.tooltip;
|
||||
|
||||
if (!dataPoints?.length || !opacity) {
|
||||
setTooltipPopup(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setTooltipPopup(
|
||||
<>
|
||||
<div>
|
||||
{formatLongNumber(dataPoints[0].raw.y)} {formatMessage(labels.visitors)}
|
||||
</div>
|
||||
<div>
|
||||
<StatusLight color={labelColors?.[0]?.backgroundColor}>
|
||||
{formatLongNumber(dataPoints[0].raw.z)}% {formatMessage(labels.dropoff)}
|
||||
</StatusLight>
|
||||
</div>
|
||||
</>,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const datasets = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: formatMessage(labels.uniqueVisitors),
|
||||
data: data,
|
||||
borderWidth: 1,
|
||||
...colors.chart.visitors,
|
||||
},
|
||||
];
|
||||
}, [data, colors, formatMessage, labels]);
|
||||
|
||||
if (loading) {
|
||||
return <Loading icon="dots" className={styles.loading} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
className={className}
|
||||
datasets={datasets}
|
||||
unit="day"
|
||||
loading={loading}
|
||||
renderXLabel={renderXLabel}
|
||||
renderTooltipPopup={renderTooltipPopup}
|
||||
XAxisType="category"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelChart;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
.loading {
|
||||
height: 300px;
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
import { useContext, useRef } from 'react';
|
||||
import { useMessages } from 'components/hooks';
|
||||
import {
|
||||
Icon,
|
||||
Form,
|
||||
FormButtons,
|
||||
FormInput,
|
||||
FormRow,
|
||||
PopupTrigger,
|
||||
Popup,
|
||||
SubmitButton,
|
||||
TextField,
|
||||
} from 'react-basics';
|
||||
import Icons from 'components/icons';
|
||||
import UrlAddForm from './UrlAddForm';
|
||||
import { ReportContext } from 'components/pages/reports/Report';
|
||||
import BaseParameters from '../BaseParameters';
|
||||
import ParameterList from '../ParameterList';
|
||||
import PopupForm from '../PopupForm';
|
||||
|
||||
export function FunnelParameters() {
|
||||
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
const ref = useRef(null);
|
||||
|
||||
const { parameters } = report || {};
|
||||
const { websiteId, dateRange, urls } = parameters || {};
|
||||
const queryDisabled = !websiteId || !dateRange || urls?.length < 2;
|
||||
|
||||
const handleSubmit = (data, e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!queryDisabled) {
|
||||
runReport(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddUrl = url => {
|
||||
updateReport({ parameters: { urls: parameters.urls.concat(url) } });
|
||||
};
|
||||
|
||||
const handleRemoveUrl = (index, e) => {
|
||||
e.stopPropagation();
|
||||
const urls = [...parameters.urls];
|
||||
urls.splice(index, 1);
|
||||
updateReport({ parameters: { urls } });
|
||||
};
|
||||
|
||||
const AddUrlButton = () => {
|
||||
return (
|
||||
<PopupTrigger>
|
||||
<Icon>
|
||||
<Icons.Plus />
|
||||
</Icon>
|
||||
<Popup position="bottom" alignment="start">
|
||||
{(close, element) => {
|
||||
return (
|
||||
<PopupForm element={element} onClose={close}>
|
||||
<UrlAddForm onAdd={handleAddUrl} />
|
||||
</PopupForm>
|
||||
);
|
||||
}}
|
||||
</Popup>
|
||||
</PopupTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form ref={ref} values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
|
||||
<BaseParameters />
|
||||
<FormRow label={formatMessage(labels.window)}>
|
||||
<FormInput
|
||||
name="window"
|
||||
rules={{ required: formatMessage(labels.required), pattern: /[0-9]+/ }}
|
||||
>
|
||||
<TextField autoComplete="off" />
|
||||
</FormInput>
|
||||
</FormRow>
|
||||
<FormRow label={formatMessage(labels.urls)} action={<AddUrlButton />}>
|
||||
<ParameterList items={urls} onRemove={handleRemoveUrl} />
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<SubmitButton variant="primary" disabled={queryDisabled} isLoading={isRunning}>
|
||||
{formatMessage(labels.runQuery)}
|
||||
</SubmitButton>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelParameters;
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import FunnelChart from './FunnelChart';
|
||||
import FunnelTable from './FunnelTable';
|
||||
import FunnelParameters from './FunnelParameters';
|
||||
import Report from '../Report';
|
||||
import ReportHeader from '../ReportHeader';
|
||||
import ReportMenu from '../ReportMenu';
|
||||
import ReportBody from '../ReportBody';
|
||||
import Funnel from 'assets/funnel.svg';
|
||||
import { REPORT_TYPES } from 'lib/constants';
|
||||
|
||||
const defaultParameters = {
|
||||
type: REPORT_TYPES.funnel,
|
||||
parameters: { window: 60, urls: [] },
|
||||
};
|
||||
|
||||
export default function FunnelReport({ reportId }) {
|
||||
return (
|
||||
<Report reportId={reportId} defaultParameters={defaultParameters}>
|
||||
<ReportHeader icon={<Funnel />} />
|
||||
<ReportMenu>
|
||||
<FunnelParameters />
|
||||
</ReportMenu>
|
||||
<ReportBody>
|
||||
<FunnelChart />
|
||||
<FunnelTable />
|
||||
</ReportBody>
|
||||
</Report>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--base400);
|
||||
border-radius: var(--border-radius);
|
||||
line-height: 32px;
|
||||
padding: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { useContext } from 'react';
|
||||
import ListTable from 'components/metrics/ListTable';
|
||||
import { useMessages } from 'components/hooks';
|
||||
import { ReportContext } from '../Report';
|
||||
|
||||
export function FunnelTable() {
|
||||
const { report } = useContext(ReportContext);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
return (
|
||||
<ListTable
|
||||
data={report?.data}
|
||||
title={formatMessage(labels.url)}
|
||||
metric={formatMessage(labels.visitors)}
|
||||
showPercentage={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default FunnelTable;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue