diff --git a/.github/ISSUE_TEMPLATE/2.feature_request.yml b/.github/ISSUE_TEMPLATE/2.feature_request.yml
index 3034767b..529a6c73 100644
--- a/.github/ISSUE_TEMPLATE/2.feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/2.feature_request.yml
@@ -1,10 +1,9 @@
-name: "✨ Feature Request"
+name: '✨ Feature Request'
description: Create a feature or enhancement request for Umami.
-labels: ['enhancement']
body:
- type: textarea
attributes:
label: Describe the feature or enhancement
description: A clear and concise description of what the feature or enhancement is.
validations:
- required: true
\ No newline at end of file
+ required: true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c24c2e6d..c140f626 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,9 +26,9 @@ jobs:
db-type: mysql
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
- uses: actions/setup-node@v2
+ uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
diff --git a/assets/add-user.svg b/assets/add-user.svg
index 9d0544c6..c6b4f484 100644
--- a/assets/add-user.svg
+++ b/assets/add-user.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/clock.svg b/assets/clock.svg
index 9c2a9a41..ab4c1dec 100644
--- a/assets/clock.svg
+++ b/assets/clock.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/dashboard.svg b/assets/dashboard.svg
index 11859d28..2090e5dc 100644
--- a/assets/dashboard.svg
+++ b/assets/dashboard.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/funnel.svg b/assets/funnel.svg
new file mode 100644
index 00000000..63fb7158
--- /dev/null
+++ b/assets/funnel.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/lightbulb.svg b/assets/lightbulb.svg
new file mode 100644
index 00000000..c7895a7d
--- /dev/null
+++ b/assets/lightbulb.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/lock.svg b/assets/lock.svg
index c13fb7c7..27fcc5e1 100644
--- a/assets/lock.svg
+++ b/assets/lock.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/logo.svg b/assets/logo.svg
index d2c71326..b1395313 100644
--- a/assets/logo.svg
+++ b/assets/logo.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/nodes.svg b/assets/nodes.svg
new file mode 100644
index 00000000..b3e22a75
--- /dev/null
+++ b/assets/nodes.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/profile.svg b/assets/profile.svg
index 133b1bc1..6a1af5a0 100644
--- a/assets/profile.svg
+++ b/assets/profile.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/user.svg b/assets/user.svg
index a75cbb8d..245a67f6 100644
--- a/assets/user.svg
+++ b/assets/user.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/users.svg b/assets/users.svg
index f775ea91..7036a22c 100644
--- a/assets/users.svg
+++ b/assets/users.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/assets/website.svg b/assets/website.svg
index cfa9e565..6096a650 100644
--- a/assets/website.svg
+++ b/assets/website.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/components/common/NoData.js b/components/common/Empty.js
similarity index 57%
rename from components/common/NoData.js
rename to components/common/Empty.js
index e9c95754..95681b16 100644
--- a/components/common/NoData.js
+++ b/components/common/Empty.js
@@ -1,15 +1,15 @@
import classNames from 'classnames';
-import styles from './NoData.module.css';
+import styles from './Empty.module.css';
import useMessages from 'hooks/useMessages';
-export function NoData({ className }) {
+export function Empty({ message, className }) {
const { formatMessage, messages } = useMessages();
return (
- {formatMessage(messages.noDataAvailable)}
+ {message || formatMessage(messages.noDataAvailable)}
);
}
-export default NoData;
+export default Empty;
diff --git a/components/common/NoData.module.css b/components/common/Empty.module.css
similarity index 100%
rename from components/common/NoData.module.css
rename to components/common/Empty.module.css
diff --git a/components/common/HoverTooltip.js b/components/common/HoverTooltip.js
index 2a98ab84..614841df 100644
--- a/components/common/HoverTooltip.js
+++ b/components/common/HoverTooltip.js
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Tooltip } from 'react-basics';
import styles from './HoverTooltip.module.css';
-export function HoverTooltip({ tooltip }) {
+export function HoverTooltip({ children }) {
const [position, setPosition] = useState({ x: -1000, y: -1000 });
useEffect(() => {
@@ -18,9 +18,9 @@ export function HoverTooltip({ tooltip }) {
}, []);
return (
- handleChange('custom')} />
) : (
options.find(e => e.value === value).label
@@ -86,12 +77,12 @@ export function DateFilter({ websiteId, value, className }) {
setShowPicker(true);
return;
}
- handleDateChange(value);
+ onChange(value);
};
const handlePickerChange = value => {
setShowPicker(false);
- handleDateChange(value);
+ onChange(value);
};
const handleClose = () => setShowPicker(false);
@@ -103,7 +94,8 @@ export function DateFilter({ websiteId, value, className }) {
items={options}
renderValue={renderValue}
value={value}
- alignment="end"
+ alignment={alignment}
+ placeholder={formatMessage(labels.selectDate)}
onChange={handleChange}
>
{({ label, value, divider }) => (
diff --git a/components/input/LanguageButton.js b/components/input/LanguageButton.js
index 1297d6c2..d4c1cbc3 100644
--- a/components/input/LanguageButton.js
+++ b/components/input/LanguageButton.js
@@ -9,8 +9,10 @@ export function LanguageButton() {
const { locale, saveLocale, dir } = useLocale();
const items = Object.keys(languages).map(key => ({ ...languages[key], value: key }));
- function handleSelect(value) {
+ function handleSelect(value, close, e) {
+ e.stopPropagation();
saveLocale(value);
+ close();
}
return (
@@ -21,24 +23,28 @@ export function LanguageButton() {
-
- {items.map(({ value, label }) => {
- return (
-
- {label}
- {value === locale && (
-
-
-
- )}
-
- );
- })}
-
+ {close => {
+ return (
+
+ {items.map(({ value, label }) => {
+ return (
+
+ {label}
+ {value === locale && (
+
+
+
+ )}
+
+ );
+ })}
+
+ );
+ }}
);
diff --git a/components/input/LanguageButton.module.css b/components/input/LanguageButton.module.css
index 16d61978..3d4c0c56 100644
--- a/components/input/LanguageButton.module.css
+++ b/components/input/LanguageButton.module.css
@@ -1,8 +1,7 @@
.menu {
display: flex;
flex-flow: row wrap;
- min-width: 600px;
- max-width: 100vw;
+ min-width: 640px;
padding: 10px;
background: var(--base50);
z-index: var(--z-index-popup);
diff --git a/components/input/LogoutButton.js b/components/input/LogoutButton.js
index 3314956e..4a15cd68 100644
--- a/components/input/LogoutButton.js
+++ b/components/input/LogoutButton.js
@@ -1,4 +1,4 @@
-import { Button, Icon, Icons, Tooltip } from 'react-basics';
+import { Button, Icon, Icons, TooltipPopup } from 'react-basics';
import Link from 'next/link';
import useMessages from 'hooks/useMessages';
@@ -6,13 +6,13 @@ export function LogoutButton({ tooltipPosition = 'top' }) {
const { formatMessage, labels } = useMessages();
return (
-
+
-
+
);
}
diff --git a/components/input/RefreshButton.js b/components/input/RefreshButton.js
index b3e2b815..444f3247 100644
--- a/components/input/RefreshButton.js
+++ b/components/input/RefreshButton.js
@@ -1,4 +1,4 @@
-import { LoadingButton, Icon, Tooltip } from 'react-basics';
+import { LoadingButton, Icon, TooltipPopup } from 'react-basics';
import { setWebsiteDateRange } from 'store/websites';
import useDateRange from 'hooks/useDateRange';
import Icons from 'components/icons';
@@ -19,13 +19,13 @@ export function RefreshButton({ websiteId, isLoading }) {
}
return (
-
+
-
+
);
}
diff --git a/components/input/ThemeButton.js b/components/input/ThemeButton.js
index b945ab7d..8ab0cdcd 100644
--- a/components/input/ThemeButton.js
+++ b/components/input/ThemeButton.js
@@ -5,7 +5,7 @@ import Icons from 'components/icons';
import styles from './ThemeButton.module.css';
export function ThemeButton() {
- const [theme, setTheme] = useTheme();
+ const { theme, saveTheme } = useTheme();
const transitions = useTransition(theme, {
initial: { opacity: 1 },
@@ -21,7 +21,7 @@ export function ThemeButton() {
});
function handleClick() {
- setTheme(theme === 'light' ? 'dark' : 'light');
+ saveTheme(theme === 'light' ? 'dark' : 'light');
}
return (
diff --git a/components/input/WebsiteDateFilter.js b/components/input/WebsiteDateFilter.js
new file mode 100644
index 00000000..71075dd7
--- /dev/null
+++ b/components/input/WebsiteDateFilter.js
@@ -0,0 +1,25 @@
+import useApi from 'hooks/useApi';
+import useDateRange from 'hooks/useDateRange';
+import DateFilter from './DateFilter';
+
+export default function WebsiteDateFilter({ websiteId, value }) {
+ const { get } = useApi();
+ const [dateRange, setDateRange] = useDateRange(websiteId);
+ const { startDate, endDate } = dateRange;
+
+ const handleChange = async value => {
+ if (value === 'all' && websiteId) {
+ const data = await get(`/websites/${websiteId}`);
+
+ if (data) {
+ setDateRange(`range:${new Date(data.createdAt)}:${Date.now()}`);
+ }
+ } else if (value !== 'all') {
+ setDateRange(value);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/components/input/WebsiteSelect.js b/components/input/WebsiteSelect.js
index a0ac38e4..b77ae57c 100644
--- a/components/input/WebsiteSelect.js
+++ b/components/input/WebsiteSelect.js
@@ -19,7 +19,6 @@ export function WebsiteSelect({ websiteId, onSelect }) {
onChange={onSelect}
alignment="end"
placeholder={formatMessage(labels.selectWebsite)}
- style={{ width: 200 }}
>
{({ id, name }) => - {name}
}
diff --git a/components/layout/AppLayout.module.css b/components/layout/AppLayout.module.css
index 6cc9e414..a83039ce 100644
--- a/components/layout/AppLayout.module.css
+++ b/components/layout/AppLayout.module.css
@@ -2,6 +2,7 @@
display: grid;
grid-template-rows: max-content 1fr;
grid-template-columns: 1fr;
+ overflow: hidden;
}
.nav {
diff --git a/components/layout/NavBar.js b/components/layout/NavBar.js
index 5a6c877e..a5ac35ef 100644
--- a/components/layout/NavBar.js
+++ b/components/layout/NavBar.js
@@ -19,6 +19,7 @@ export function NavBar() {
const links = [
{ label: formatMessage(labels.dashboard), url: '/dashboard' },
{ label: formatMessage(labels.realtime), url: '/realtime' },
+ { label: formatMessage(labels.reports), url: '/reports' },
!cloudMode && { label: formatMessage(labels.settings), url: '/settings' },
].filter(n => n);
diff --git a/components/layout/NavBar.module.css b/components/layout/NavBar.module.css
index 05dce2af..dd5085a0 100644
--- a/components/layout/NavBar.module.css
+++ b/components/layout/NavBar.module.css
@@ -27,7 +27,6 @@
gap: 10px;
font-size: 16px;
font-weight: 700;
- cursor: pointer;
min-width: 0;
}
diff --git a/components/layout/NavGroup.js b/components/layout/NavGroup.js
index b9e7155d..94f9d8e6 100644
--- a/components/layout/NavGroup.js
+++ b/components/layout/NavGroup.js
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { Icon, Text, Tooltip } from 'react-basics';
+import { Icon, Text, TooltipPopup } from 'react-basics';
import classNames from 'classnames';
import { useRouter } from 'next/router';
import Link from 'next/link';
@@ -36,7 +36,7 @@ export function NavGroup({
{items.map(({ label, url, icon, divider }) => {
return (
-
+
{icon}
{label}
-
+
);
})}
diff --git a/components/layout/PageHeader.js b/components/layout/PageHeader.js
index bf243c21..f1363140 100644
--- a/components/layout/PageHeader.js
+++ b/components/layout/PageHeader.js
@@ -1,10 +1,11 @@
+import classNames from 'classnames';
import React from 'react';
import styles from './PageHeader.module.css';
-export function PageHeader({ title, children }) {
+export function PageHeader({ title, children, className }) {
return (
-
-
{title}
+
+ {title &&
{title}
}
{children}
);
diff --git a/components/layout/PageHeader.module.css b/components/layout/PageHeader.module.css
index 5ea85b70..03a1c7c8 100644
--- a/components/layout/PageHeader.module.css
+++ b/components/layout/PageHeader.module.css
@@ -4,7 +4,6 @@
align-items: center;
align-content: center;
align-self: stretch;
- margin-bottom: 40px;
flex-wrap: wrap;
}
@@ -23,6 +22,7 @@
font-weight: 700;
gap: 20px;
height: 60px;
+ flex: 1;
}
.actions {
diff --git a/components/layout/ReportsLayout.js b/components/layout/ReportsLayout.js
new file mode 100644
index 00000000..fd63a67e
--- /dev/null
+++ b/components/layout/ReportsLayout.js
@@ -0,0 +1,23 @@
+import { Column, Row } from 'react-basics';
+import styles from './ReportsLayout.module.css';
+
+export function SettingsLayout({ children, filter, header }) {
+ return (
+ <>
+
{header}
+
+ {filter && (
+
+ Filters
+ {filter}
+
+ )}
+
+ {children}
+
+
+ >
+ );
+}
+
+export default SettingsLayout;
diff --git a/components/layout/ReportsLayout.module.css b/components/layout/ReportsLayout.module.css
new file mode 100644
index 00000000..6922665f
--- /dev/null
+++ b/components/layout/ReportsLayout.module.css
@@ -0,0 +1,23 @@
+.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;
+ }
+}
diff --git a/components/messages.js b/components/messages.js
index aa268225..016d3c4c 100644
--- a/components/messages.js
+++ b/components/messages.js
@@ -18,6 +18,7 @@ export const labels = defineMessages({
admin: { id: 'label.admin', defaultMessage: 'Administrator' },
confirm: { id: 'label.confirm', defaultMessage: 'Confirm' },
details: { id: 'label.details', defaultMessage: 'Details' },
+ website: { id: 'label.website', defaultMessage: 'Website' },
websites: { id: 'label.websites', defaultMessage: 'Websites' },
created: { id: 'label.created', defaultMessage: 'Created' },
edit: { id: 'label.edit', defaultMessage: 'Edit' },
@@ -47,6 +48,8 @@ export const labels = defineMessages({
deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' },
reset: { id: 'label.reset', defaultMessage: 'Reset' },
addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' },
+ addField: { id: 'label.add-field', defaultMessage: 'Add field' },
+ addDescription: { id: 'label.add-description', defaultMessage: 'Add description' },
changePassword: { id: 'label.change-password', defaultMessage: 'Change password' },
currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' },
newPassword: { id: 'label.new-password', defaultMessage: 'New password' },
@@ -79,7 +82,8 @@ export const labels = defineMessages({
countries: { id: 'label.countries', defaultMessage: 'Countries' },
languages: { id: 'label.languages', defaultMessage: 'Languages' },
events: { id: 'label.events', defaultMessage: 'Events' },
- query: { id: 'label.query-parameters', defaultMessage: 'Query parameters' },
+ query: { id: 'label.query', defaultMessage: 'Query' },
+ queryParameters: { id: 'label.query-parameters', defaultMessage: 'Query parameters' },
back: { id: 'label.back', defaultMessage: 'Back' },
visitors: { id: 'label.visitors', defaultMessage: 'Visitors' },
filterCombined: { id: 'label.filter-combined', defaultMessage: 'Combined' },
@@ -97,6 +101,7 @@ export const labels = defineMessages({
allTime: { id: 'label.all-time', defaultMessage: 'All time' },
customRange: { id: 'label.custom-range', defaultMessage: 'Custom range' },
selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' },
+ selectDate: { id: 'label.select-date', defaultMessage: 'Select date' },
all: { id: 'label.all', defaultMessage: 'All' },
sessions: { id: 'label.sessions', defaultMessage: 'Sessions' },
pageNotFound: { id: 'message.page-not-found', defaultMessage: 'Page not found' },
@@ -117,6 +122,34 @@ export const labels = defineMessages({
view: { id: 'label.view', defaultMessage: 'View' },
cities: { id: 'label.cities', defaultMessage: 'Cities' },
regions: { id: 'label.regions', defaultMessage: 'Regions' },
+ reports: { id: 'label.reports', defaultMessage: 'Reports' },
+ eventData: { id: 'label.event-data', defaultMessage: 'Event data' },
+ funnel: { id: 'label.funnel', defaultMessage: 'Funnel' },
+ url: { id: 'label.url', defaultMessage: 'URL' },
+ urls: { id: 'label.urls', defaultMessage: 'URLs' },
+ add: { id: 'label.add', defaultMessage: 'Add' },
+ window: { id: 'label.window', defaultMessage: 'Window' },
+ runQuery: { id: 'label.run-query', defaultMessage: 'Run query' },
+ field: { id: 'label.field', defaultMessage: 'Field' },
+ fields: { id: 'label.fields', defaultMessage: 'Fields' },
+ createReport: { id: 'labels.create-report', defaultMessage: 'Create report' },
+ description: { id: 'labels.description', defaultMessage: 'Description' },
+ untitled: { id: 'labels.untitled', defaultMessage: 'Untitled' },
+ type: { id: 'labels.type', defaultMessage: 'Type' },
+ filters: { id: 'labels.filters', defaultMessage: 'Filters' },
+ groupBy: { id: 'labels.group-by', defaultMessage: 'Group by' },
+ true: { id: 'labels.true', defaultMessage: 'True' },
+ false: { id: 'labels.false', defaultMessage: 'False' },
+ equals: { id: 'labels.equals', defaultMessage: 'Equals' },
+ doesNotEqual: { id: 'labels.does-not-equal', defaultMessage: 'Does not equal' },
+ greaterThan: { id: 'labels.greater-than', defaultMessage: 'Greater than' },
+ lessThan: { id: 'labels.less-than', defaultMessage: 'Less than' },
+ greaterThanEquals: { id: 'labels.greater-than-equals', defaultMessage: 'Greater than or equals' },
+ lessThanEquals: { id: 'labels.less-than-equals', defaultMessage: 'Less than or equals' },
+ contains: { id: 'labels.contains', defaultMessage: 'Contains' },
+ doesNotContain: { id: 'labels.does-not-contain', defaultMessage: 'Does not contain' },
+ before: { id: 'labels.before', defaultMessage: 'Before' },
+ after: { id: 'labels.after', defaultMessage: 'After' },
});
export const messages = defineMessages({
@@ -158,6 +191,10 @@ export const messages = defineMessages({
id: 'message.team-already-member',
defaultMessage: 'You are already a member of the team.',
},
+ deleteAccount: {
+ id: 'message.delete-account',
+ defaultMessage: 'To delete this account, type {confirmation} in the box below to confirm.',
+ },
deleteWebsite: {
id: 'message.delete-website',
defaultMessage: 'To delete this website, type {confirmation} in the box below to confirm.',
@@ -179,6 +216,10 @@ export const messages = defineMessages({
id: 'message.delete-website-warning',
defaultMessage: 'All website data will be deleted.',
},
+ noResultsFound: {
+ id: 'messages.no-results-found',
+ defaultMessage: 'No results were found.',
+ },
noWebsitesConfigured: {
id: 'messages.no-websites-configured',
defaultMessage: 'You do not have any websites configured.',
@@ -216,4 +257,8 @@ export const messages = defineMessages({
id: 'message.incorrect-username-password',
defaultMessage: 'Incorrect username and/or password.',
},
+ noEventData: {
+ id: 'message.no-event-data',
+ defaultMessage: 'No event data is available.',
+ },
});
diff --git a/components/metrics/BarChart.js b/components/metrics/BarChart.js
index cd7070e8..c086017e 100644
--- a/components/metrics/BarChart.js
+++ b/components/metrics/BarChart.js
@@ -1,14 +1,13 @@
-import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
-import { StatusLight, Loading } from 'react-basics';
+import { useState, useRef, useEffect, useCallback } from 'react';
+import { Loading } from 'react-basics';
import classNames from 'classnames';
import Chart from 'chart.js/auto';
import HoverTooltip from 'components/common/HoverTooltip';
import Legend from 'components/metrics/Legend';
-import { formatLongNumber } from 'lib/format';
-import { dateFormat } from 'lib/date';
import useLocale from 'hooks/useLocale';
import useTheme from 'hooks/useTheme';
-import { DEFAULT_ANIMATION_DURATION, THEME_COLORS } from 'lib/constants';
+import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
+import { renderNumberLabels } from 'lib/charts';
import styles from './BarChart.module.css';
export function BarChart({
@@ -17,84 +16,20 @@ export function BarChart({
animationDuration = DEFAULT_ANIMATION_DURATION,
stacked = false,
loading = false,
- onCreate = () => {},
- onUpdate = () => {},
+ renderXLabel,
+ renderYLabel,
+ XAxisType = 'time',
+ YAxisType = 'linear',
+ renderTooltipPopup,
+ onCreate,
+ onUpdate,
className,
}) {
const canvas = useRef();
const chart = useRef(null);
- const [tooltip, setTooltip] = useState(null);
+ const [tooltip, setTooltipPopup] = useState(null);
const { locale } = useLocale();
- const [theme] = useTheme();
-
- const colors = useMemo(
- () => ({
- text: THEME_COLORS[theme].gray700,
- line: THEME_COLORS[theme].gray200,
- }),
- [theme],
- );
-
- const renderYLabel = label => {
- return +label > 1000 ? formatLongNumber(label) : label;
- };
-
- const renderXLabel = useCallback(
- (label, index, values) => {
- const d = new Date(values[index].value);
-
- switch (unit) {
- case 'minute':
- return dateFormat(d, 'h:mm', locale);
- case 'hour':
- return dateFormat(d, 'p', locale);
- case 'day':
- return dateFormat(d, 'MMM d', locale);
- case 'month':
- return dateFormat(d, 'MMM', locale);
- default:
- return label;
- }
- },
- [locale, unit],
- );
-
- const renderTooltip = useCallback(
- model => {
- const { opacity, labelColors, dataPoints } = model.tooltip;
-
- if (!dataPoints?.length || !opacity) {
- setTooltip(null);
- return;
- }
-
- const formats = {
- millisecond: 'T',
- second: 'pp',
- minute: 'p',
- hour: 'h:mm aaa - PP',
- day: 'PPPP',
- week: 'PPPP',
- month: 'LLLL yyyy',
- quarter: 'qqq',
- year: 'yyyy',
- };
-
- setTooltip(
-
-
{dateFormat(new Date(dataPoints[0].raw.x), formats[unit], locale)}
-
-
-
- {formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label}
-
-
-
-
,
- );
- },
- [unit],
- );
+ const { theme, colors } = useTheme();
const getOptions = useCallback(() => {
return {
@@ -115,12 +50,12 @@ export function BarChart({
},
tooltip: {
enabled: false,
- external: renderTooltip,
+ external: renderTooltipPopup ? renderTooltipPopup.bind(null, setTooltipPopup) : undefined,
},
},
scales: {
x: {
- type: 'time',
+ type: XAxisType,
stacked: true,
time: {
unit,
@@ -129,34 +64,44 @@ export function BarChart({
display: false,
},
border: {
- color: colors.line,
+ color: colors.chart.line,
},
ticks: {
- color: colors.text,
+ color: colors.chart.text,
autoSkip: false,
maxRotation: 0,
callback: renderXLabel,
},
},
y: {
- type: 'linear',
+ type: YAxisType,
min: 0,
beginAtZero: true,
stacked,
grid: {
- color: colors.line,
+ color: colors.chart.line,
},
border: {
- color: colors.line,
+ color: colors.chart.line,
},
ticks: {
color: colors.text,
- callback: renderYLabel,
+ callback: renderYLabel || renderNumberLabels,
},
},
},
};
- }, [animationDuration, renderTooltip, renderXLabel, stacked, colors, unit, locale]);
+ }, [
+ animationDuration,
+ renderTooltipPopup,
+ renderXLabel,
+ XAxisType,
+ YAxisType,
+ stacked,
+ colors,
+ unit,
+ locale,
+ ]);
const createChart = () => {
Chart.defaults.font.family = 'Inter';
@@ -171,11 +116,11 @@ export function BarChart({
options,
});
- onCreate(chart.current);
+ onCreate?.(chart.current);
};
const updateChart = () => {
- setTooltip(null);
+ setTooltipPopup(null);
datasets.forEach((dataset, index) => {
chart.current.data.datasets[index].data = dataset.data;
@@ -184,7 +129,7 @@ export function BarChart({
chart.current.options = getOptions();
- onUpdate(chart.current);
+ onUpdate?.(chart.current);
chart.current.update();
};
@@ -206,7 +151,11 @@ export function BarChart({
- {tooltip && }
+ {tooltip && (
+
+ {tooltip}
+
+ )}
>
);
}
diff --git a/components/metrics/BarChart.module.css b/components/metrics/BarChart.module.css
index 850d1ea7..f2e26db1 100644
--- a/components/metrics/BarChart.module.css
+++ b/components/metrics/BarChart.module.css
@@ -13,9 +13,3 @@
.tooltip .value {
text-transform: lowercase;
}
-
-@media only screen and (max-width: 992px) {
- .chart {
- /*height: 200px;*/
- }
-}
diff --git a/components/metrics/DataTable.js b/components/metrics/DataTable.js
index 086f98ae..e2e9462d 100644
--- a/components/metrics/DataTable.js
+++ b/components/metrics/DataTable.js
@@ -3,10 +3,10 @@ import useMeasure from 'react-use-measure';
import { FixedSizeList } from 'react-window';
import { useSpring, animated, config } from 'react-spring';
import classNames from 'classnames';
-import NoData from 'components/common/NoData';
+import Empty from 'components/common/Empty';
import { formatNumber, formatLongNumber } from 'lib/format';
+import useMessages from 'hooks/useMessages';
import styles from './DataTable.module.css';
-import useMessages from '../../hooks/useMessages';
export function DataTable({
data = [],
@@ -55,7 +55,7 @@ export function DataTable({
- {data?.length === 0 && }
+ {data?.length === 0 && }
{virtualize && data.length > 0 ? (
{Row}
diff --git a/components/metrics/DataTable.module.css b/components/metrics/DataTable.module.css
index c5b2bd7c..04e12e9b 100644
--- a/components/metrics/DataTable.module.css
+++ b/components/metrics/DataTable.module.css
@@ -1,9 +1,9 @@
.table {
position: relative;
- height: 100%;
display: grid;
grid-template-rows: fit-content(100%) auto;
overflow: hidden;
+ flex: 1;
}
.body {
diff --git a/components/metrics/DatePickerForm.js b/components/metrics/DatePickerForm.js
index 96730591..53f027bb 100644
--- a/components/metrics/DatePickerForm.js
+++ b/components/metrics/DatePickerForm.js
@@ -2,7 +2,6 @@ import { useState } from 'react';
import { Button, ButtonGroup, Calendar } from 'react-basics';
import { isAfter, isBefore, isSameDay } from 'date-fns';
import useLocale from 'hooks/useLocale';
-import { getDateRangeValues } from 'lib/date';
import { getDateLocale } from 'lib/lang';
import { FILTER_DAY, FILTER_RANGE } from 'lib/constants';
import useMessages from 'hooks/useMessages';
@@ -19,7 +18,7 @@ export function DatePickerForm({
const [selected, setSelected] = useState(
isSameDay(defaultStartDate, defaultEndDate) ? FILTER_DAY : FILTER_RANGE,
);
- const [date, setDate] = useState(defaultStartDate);
+ const [singleDate, setSingleDate] = useState(defaultStartDate);
const [startDate, setStartDate] = useState(defaultStartDate);
const [endDate, setEndDate] = useState(defaultEndDate);
const { locale } = useLocale();
@@ -27,14 +26,14 @@ export function DatePickerForm({
const disabled =
selected === FILTER_DAY
- ? isAfter(minDate, date) && isBefore(maxDate, date)
+ ? isAfter(minDate, singleDate) && isBefore(maxDate, singleDate)
: isAfter(startDate, endDate);
const handleSave = () => {
if (selected === FILTER_DAY) {
- onChange({ ...getDateRangeValues(date, date), value: 'custom' });
+ onChange(`range:${singleDate.getTime()}:${singleDate.getTime()}`);
} else {
- onChange({ ...getDateRangeValues(startDate, endDate), value: 'custom' });
+ onChange(`range:${startDate.getTime()}:${endDate.getTime()}`);
}
};
@@ -48,7 +47,12 @@ export function DatePickerForm({
{selected === FILTER_DAY && (
-
+
)}
{selected === FILTER_RANGE && (
<>
diff --git a/components/metrics/EventsChart.js b/components/metrics/EventsChart.js
index eb397cc9..82b8c8f7 100644
--- a/components/metrics/EventsChart.js
+++ b/components/metrics/EventsChart.js
@@ -2,16 +2,15 @@ import { useMemo } from 'react';
import { Loading } from 'react-basics';
import { colord } from 'colord';
import BarChart from './BarChart';
-import { getDateArray, getDateLength } from 'lib/date';
-import useApi from 'hooks/useApi';
-import useDateRange from 'hooks/useDateRange';
-import useTimezone from 'hooks/useTimezone';
-import usePageQuery from 'hooks/usePageQuery';
+import { getDateArray } from 'lib/date';
+import { useApi, useLocale, useDateRange, useTimezone, usePageQuery } from 'hooks';
import { EVENT_COLORS } from 'lib/constants';
+import { renderDateLabels, renderStatusTooltipPopup } from 'lib/charts';
export function EventsChart({ websiteId, className, token }) {
const { get, useQuery } = useApi();
const [{ startDate, endDate, unit, modified }] = useDateRange(websiteId);
+ const { locale } = useLocale();
const [timezone] = useTimezone();
const {
query: { url, eventName },
@@ -70,9 +69,10 @@ export function EventsChart({ websiteId, className, token }) {
datasets={datasets}
unit={unit}
height={300}
- records={getDateLength(startDate, endDate, unit)}
loading={isLoading}
stacked
+ renderXLabel={renderDateLabels(unit, locale)}
+ renderTooltipPopup={renderStatusTooltipPopup(unit, locale)}
/>
);
}
diff --git a/components/metrics/MetricsBar.js b/components/metrics/MetricsBar.js
index 25b93115..ccaf627c 100644
--- a/components/metrics/MetricsBar.js
+++ b/components/metrics/MetricsBar.js
@@ -16,13 +16,13 @@ export function MetricsBar({ websiteId }) {
const { startDate, endDate, modified } = dateRange;
const [format, setFormat] = useState(true);
const {
- query: { url, referrer, os, browser, device, country, region, city },
+ query: { url, referrer, title, os, browser, device, country, region, city },
} = usePageQuery();
const { data, error, isLoading, isFetched } = useQuery(
[
'websites:stats',
- { websiteId, modified, url, referrer, os, browser, device, country, region, city },
+ { websiteId, modified, url, referrer, title, os, browser, device, country, region, city },
],
() =>
get(`/websites/${websiteId}/stats`, {
@@ -30,6 +30,7 @@ export function MetricsBar({ websiteId }) {
endAt: +endDate,
url,
referrer,
+ title,
os,
browser,
device,
diff --git a/components/metrics/MetricsBar.module.css b/components/metrics/MetricsBar.module.css
index 0e305c70..eaf81c48 100644
--- a/components/metrics/MetricsBar.module.css
+++ b/components/metrics/MetricsBar.module.css
@@ -1,7 +1,7 @@
.bar {
display: flex;
cursor: pointer;
- min-height: 80px;
+ min-height: 110px;
gap: 20px;
flex-wrap: wrap;
}
diff --git a/components/metrics/MetricsTable.js b/components/metrics/MetricsTable.js
index 97deb39d..50262798 100644
--- a/components/metrics/MetricsTable.js
+++ b/components/metrics/MetricsTable.js
@@ -30,7 +30,7 @@ export function MetricsTable({
const {
resolveUrl,
router,
- query: { url, referrer, os, browser, device, country, region, city },
+ query: { url, referrer, title, os, browser, device, country, region, city },
} = usePageQuery();
const { formatMessage, labels } = useMessages();
const { get, useQuery } = useApi();
@@ -38,7 +38,20 @@ export function MetricsTable({
const { data, isLoading, isFetched, error } = useQuery(
[
'websites:metrics',
- { websiteId, type, modified, url, referrer, os, browser, device, country, region, city },
+ {
+ websiteId,
+ type,
+ modified,
+ url,
+ referrer,
+ os,
+ title,
+ browser,
+ device,
+ country,
+ region,
+ city,
+ },
],
() =>
get(`/websites/${websiteId}/metrics`, {
@@ -46,6 +59,7 @@ export function MetricsTable({
startAt: +startDate,
endAt: +endDate,
url,
+ title,
referrer,
os,
browser,
@@ -59,13 +73,27 @@ export function MetricsTable({
const filteredData = useMemo(() => {
if (data) {
- let items = percentFilter(dataFilter ? dataFilter(data, filterOptions) : data);
+ let items = data;
+
+ if (dataFilter) {
+ if (Array.isArray(dataFilter)) {
+ items = dataFilter.reduce((arr, filter) => {
+ return filter(arr);
+ }, items);
+ } else {
+ items = dataFilter(data);
+ }
+ }
+
+ items = percentFilter(items);
+
if (limit) {
items = items.filter((e, i) => i < limit);
}
if (filterOptions?.sort === false) {
return items;
}
+
return items.sort(firstBy('y', -1).thenBy('x'));
}
return [];
diff --git a/components/metrics/PageviewsChart.js b/components/metrics/PageviewsChart.js
index 6ea16226..362c616e 100644
--- a/components/metrics/PageviewsChart.js
+++ b/components/metrics/PageviewsChart.js
@@ -1,34 +1,13 @@
import { useMemo } from 'react';
-import { colord } from 'colord';
import BarChart from './BarChart';
-import { THEME_COLORS } from 'lib/constants';
-import useTheme from 'hooks/useTheme';
-import useMessages from 'hooks/useMessages';
-import useLocale from 'hooks/useLocale';
+import { useLocale, useTheme, useMessages } from 'hooks';
+import { renderDateLabels, renderStatusTooltipPopup } from 'lib/charts';
-export function PageviewsChart({ websiteId, data, unit, records, className, loading, ...props }) {
+export function PageviewsChart({ websiteId, data, unit, className, loading, ...props }) {
const { formatMessage, labels } = useMessages();
- const [theme] = useTheme();
+ const { colors } = useTheme();
const { locale } = useLocale();
- const colors = useMemo(() => {
- const primaryColor = colord(THEME_COLORS[theme].primary);
- return {
- views: {
- hoverBackgroundColor: primaryColor.alpha(0.7).toRgbString(),
- backgroundColor: primaryColor.alpha(0.4).toRgbString(),
- borderColor: primaryColor.alpha(0.7).toRgbString(),
- hoverBorderColor: primaryColor.toRgbString(),
- },
- visitors: {
- hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(),
- backgroundColor: primaryColor.alpha(0.6).toRgbString(),
- borderColor: primaryColor.alpha(0.9).toRgbString(),
- hoverBorderColor: primaryColor.toRgbString(),
- },
- };
- }, [theme]);
-
const datasets = useMemo(() => {
if (!data) return [];
@@ -37,13 +16,13 @@ export function PageviewsChart({ websiteId, data, unit, records, className, load
label: formatMessage(labels.uniqueVisitors),
data: data.sessions,
borderWidth: 1,
- ...colors.visitors,
+ ...colors.chart.visitors,
},
{
label: formatMessage(labels.pageViews),
data: data.pageviews,
borderWidth: 1,
- ...colors.views,
+ ...colors.chart.views,
},
];
}, [data, locale, colors]);
@@ -55,8 +34,9 @@ export function PageviewsChart({ websiteId, data, unit, records, className, load
className={className}
datasets={datasets}
unit={unit}
- records={records}
loading={loading}
+ renderXLabel={renderDateLabels(unit, locale)}
+ renderTooltipPopup={renderStatusTooltipPopup(unit, locale)}
/>
);
}
diff --git a/components/metrics/QueryParametersTable.js b/components/metrics/QueryParametersTable.js
index c5f573e3..23193c2e 100644
--- a/components/metrics/QueryParametersTable.js
+++ b/components/metrics/QueryParametersTable.js
@@ -9,7 +9,7 @@ import styles from './QueryParametersTable.module.css';
const filters = {
[FILTER_RAW]: emptyFilter,
- [FILTER_COMBINED]: paramFilter,
+ [FILTER_COMBINED]: [emptyFilter, paramFilter],
};
export function QueryParametersTable({ websiteId, showFilters, ...props }) {
diff --git a/components/metrics/WebsiteChart.js b/components/metrics/WebsiteChart.js
index 6614d40f..7b902df1 100644
--- a/components/metrics/WebsiteChart.js
+++ b/components/metrics/WebsiteChart.js
@@ -5,7 +5,7 @@ import classNames from 'classnames';
import PageviewsChart from './PageviewsChart';
import MetricsBar from './MetricsBar';
import WebsiteHeader from './WebsiteHeader';
-import DateFilter from 'components/input/DateFilter';
+import WebsiteDateFilter from 'components/input/WebsiteDateFilter';
import ErrorMessage from 'components/common/ErrorMessage';
import FilterTags from 'components/metrics/FilterTags';
import RefreshButton from 'components/input/RefreshButton';
@@ -107,7 +107,7 @@ export function WebsiteChart({
-
+
diff --git a/components/metrics/WebsiteHeader.module.css b/components/metrics/WebsiteHeader.module.css
index e5ebcca7..68fd22f8 100644
--- a/components/metrics/WebsiteHeader.module.css
+++ b/components/metrics/WebsiteHeader.module.css
@@ -1,3 +1,9 @@
+.header {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+}
+
.title {
display: flex;
flex-direction: row;
diff --git a/components/pages/console/TestConsole.js b/components/pages/console/TestConsole.js
index 745bf94c..eda93f0b 100644
--- a/components/pages/console/TestConsole.js
+++ b/components/pages/console/TestConsole.js
@@ -28,20 +28,41 @@ export function TestConsole() {
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', {
- data: {
+ test: 'test-data',
+ boolean: true,
+ booleanError: 'true',
+ time: new Date(),
+ number: 1,
+ time2: new Date().toISOString(),
+ nested: {
test: 'test-data',
- time: new Date(),
number: 1,
- time2: new Date().toISOString(),
- nested: {
+ object: {
test: 'test-data',
- number: 1,
- object: {
- test: 'test-data',
- },
},
- array: [1, 2, 3],
},
+ 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],
});
}
@@ -114,6 +135,10 @@ export function TestConsole() {
Run script
+
+
+ Run identify
+
diff --git a/components/pages/realtime/RealtimeLog.js b/components/pages/realtime/RealtimeLog.js
index ddd35751..fe12963c 100644
--- a/components/pages/realtime/RealtimeLog.js
+++ b/components/pages/realtime/RealtimeLog.js
@@ -3,7 +3,7 @@ import { StatusLight, Icon, Text } from 'react-basics';
import { FixedSizeList } from 'react-window';
import firstBy from 'thenby';
import FilterButtons from 'components/common/FilterButtons';
-import NoData from 'components/common/NoData';
+import Empty from 'components/common/Empty';
import useLocale from 'hooks/useLocale';
import useCountryNames from 'hooks/useCountryNames';
import { BROWSERS } from 'lib/constants';
@@ -144,7 +144,7 @@ export function RealtimeLog({ data, websiteDomain }) {
{formatMessage(labels.activityLog)}
- {logs?.length === 0 &&
}
+ {logs?.length === 0 &&
}
{logs?.length > 0 && (
{Row}
diff --git a/components/pages/realtime/RealtimeUrls.js b/components/pages/realtime/RealtimeUrls.js
index dfbf1fda..18d8f2f6 100644
--- a/components/pages/realtime/RealtimeUrls.js
+++ b/components/pages/realtime/RealtimeUrls.js
@@ -10,6 +10,7 @@ export function RealtimeUrls({ websiteDomain, data = {} }) {
const { formatMessage, labels } = useMessages();
const { pageviews } = data;
const [filter, setFilter] = useState(FILTER_REFERRERS);
+ const limit = 15;
const buttons = [
{
@@ -47,7 +48,8 @@ export function RealtimeUrls({ websiteDomain, data = {} }) {
}
return arr;
}, [])
- .sort(firstBy('y', -1)),
+ .sort(firstBy('y', -1))
+ .slice(0, limit),
);
const pages = percentFilter(
@@ -62,7 +64,8 @@ export function RealtimeUrls({ websiteDomain, data = {} }) {
}
return arr;
}, [])
- .sort(firstBy('y', -1)),
+ .sort(firstBy('y', -1))
+ .slice(0, limit),
);
return [referrers, pages];
diff --git a/components/pages/reports/BaseParameters.js b/components/pages/reports/BaseParameters.js
new file mode 100644
index 00000000..5dbe0f60
--- /dev/null
+++ b/components/pages/reports/BaseParameters.js
@@ -0,0 +1,43 @@
+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 'hooks';
+
+export function BaseParameters() {
+ 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 (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
+
+export default BaseParameters;
diff --git a/components/pages/reports/FieldAggregateForm.js b/components/pages/reports/FieldAggregateForm.js
new file mode 100644
index 00000000..f4298c16
--- /dev/null
+++ b/components/pages/reports/FieldAggregateForm.js
@@ -0,0 +1,38 @@
+import { Form, FormRow, Menu, Item } from 'react-basics';
+
+const options = {
+ number: [
+ { label: 'SUM', value: 'sum' },
+ { label: 'AVERAGE', value: 'average' },
+ { label: 'MIN', value: 'min' },
+ { label: 'MAX', value: 'max' },
+ ],
+ date: [
+ { label: 'MIN', value: 'min' },
+ { label: 'MAX', value: 'max' },
+ ],
+ string: [
+ { label: 'COUNT', value: 'count' },
+ { label: 'DISTINCT', value: 'distinct' },
+ ],
+};
+
+export default function FieldAggregateForm({ name, type, onSelect }) {
+ const items = options[type];
+
+ const handleSelect = value => {
+ onSelect({ name, value });
+ };
+
+ return (
+
+ );
+}
diff --git a/components/pages/reports/FieldFilterForm.js b/components/pages/reports/FieldFilterForm.js
new file mode 100644
index 00000000..e4272c69
--- /dev/null
+++ b/components/pages/reports/FieldFilterForm.js
@@ -0,0 +1,57 @@
+import { useState } from 'react';
+import { Form, FormRow, Menu, Item, Flexbox, Dropdown, TextField, Button } from 'react-basics';
+import { useFilters } from 'hooks';
+import styles from './FieldFilterForm.module.css';
+
+export default function FieldFilterForm({ name, type, onSelect }) {
+ const [filter, setFilter] = useState('');
+ const [value, setValue] = useState('');
+ const { filters, types } = useFilters();
+ const items = types[type];
+
+ const renderValue = value => {
+ return filters[value];
+ };
+
+ if (type === 'boolean') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/components/pages/reports/FieldFilterForm.module.css b/components/pages/reports/FieldFilterForm.module.css
new file mode 100644
index 00000000..f0cc46f3
--- /dev/null
+++ b/components/pages/reports/FieldFilterForm.module.css
@@ -0,0 +1,17 @@
+.selected {
+ font-weight: bold;
+}
+
+.popup {
+ display: flex;
+}
+
+.filter {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.dropdown {
+ min-width: 60px;
+}
diff --git a/components/pages/reports/FieldSelectForm.js b/components/pages/reports/FieldSelectForm.js
new file mode 100644
index 00000000..1ff6412a
--- /dev/null
+++ b/components/pages/reports/FieldSelectForm.js
@@ -0,0 +1,24 @@
+import { Menu, Item, Form, FormRow } from 'react-basics';
+import { useMessages } from 'hooks';
+import styles from './FieldSelectForm.module.css';
+
+export default function FieldSelectForm({ fields, onSelect }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+
+ );
+}
diff --git a/components/pages/reports/FieldSelectForm.module.css b/components/pages/reports/FieldSelectForm.module.css
new file mode 100644
index 00000000..3a5ed9b8
--- /dev/null
+++ b/components/pages/reports/FieldSelectForm.module.css
@@ -0,0 +1,20 @@
+.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);
+}
diff --git a/components/pages/reports/ParameterList.js b/components/pages/reports/ParameterList.js
new file mode 100644
index 00000000..604f6223
--- /dev/null
+++ b/components/pages/reports/ParameterList.js
@@ -0,0 +1,33 @@
+import { Icon, Text, TooltipPopup } from 'react-basics';
+import Icons from 'components/icons';
+import Empty from 'components/common/Empty';
+import { useMessages } from 'hooks';
+import styles from './ParameterList.module.css';
+
+export function ParameterList({ items = [], children, onRemove }) {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+
+ {!items.length &&
}
+ {items.map((item, index) => {
+ return (
+
+ {typeof children === 'function' ? children(item) : item}
+
+
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+export default ParameterList;
diff --git a/components/pages/reports/ParameterList.module.css b/components/pages/reports/ParameterList.module.css
new file mode 100644
index 00000000..601b37e5
--- /dev/null
+++ b/components/pages/reports/ParameterList.module.css
@@ -0,0 +1,20 @@
+.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);
+}
+
+.icon {
+ align-self: center;
+}
diff --git a/components/pages/reports/PopupForm.js b/components/pages/reports/PopupForm.js
new file mode 100644
index 00000000..0f0ead36
--- /dev/null
+++ b/components/pages/reports/PopupForm.js
@@ -0,0 +1,30 @@
+import { createPortal } from 'react-dom';
+import { useDocumentClick, useKeyDown } from 'react-basics';
+import classNames from 'classnames';
+import styles from './PopupForm.module.css';
+
+export function PopupForm({ element, className, children, onClose }) {
+ const { right, top } = element.getBoundingClientRect();
+ const style = { position: 'absolute', left: right, top };
+
+ useKeyDown('Escape', onClose);
+
+ useDocumentClick(e => {
+ if (e.target !== element && !element?.parentElement?.contains(e.target)) {
+ onClose();
+ }
+ });
+
+ const handleClick = e => {
+ e.stopPropagation();
+ };
+
+ return createPortal(
+
+ {children}
+
,
+ document.body,
+ );
+}
+
+export default PopupForm;
diff --git a/components/pages/reports/PopupForm.module.css b/components/pages/reports/PopupForm.module.css
new file mode 100644
index 00000000..4daf199a
--- /dev/null
+++ b/components/pages/reports/PopupForm.module.css
@@ -0,0 +1,10 @@
+.form {
+ position: absolute;
+ background: var(--base50);
+ min-width: 300px;
+ padding: 20px;
+ margin-left: 30px;
+ border: 1px solid var(--base400);
+ border-radius: var(--border-radius);
+ box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1);
+}
diff --git a/components/pages/reports/Report.js b/components/pages/reports/Report.js
new file mode 100644
index 00000000..685ebb9f
--- /dev/null
+++ b/components/pages/reports/Report.js
@@ -0,0 +1,22 @@
+import { createContext } from 'react';
+import Page from 'components/layout/Page';
+import styles from './reports.module.css';
+import { useReport } from 'hooks';
+
+export const ReportContext = createContext(null);
+
+export function Report({ reportId, defaultParameters, children, ...props }) {
+ const report = useReport(reportId, defaultParameters);
+
+ //console.log({ report });
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export default Report;
diff --git a/components/pages/reports/ReportBody.js b/components/pages/reports/ReportBody.js
new file mode 100644
index 00000000..2310c8af
--- /dev/null
+++ b/components/pages/reports/ReportBody.js
@@ -0,0 +1,7 @@
+import styles from './reports.module.css';
+
+export function ReportBody({ children }) {
+ return {children}
;
+}
+
+export default ReportBody;
diff --git a/components/pages/reports/ReportDetails.js b/components/pages/reports/ReportDetails.js
new file mode 100644
index 00000000..c41d12f6
--- /dev/null
+++ b/components/pages/reports/ReportDetails.js
@@ -0,0 +1,13 @@
+import FunnelReport from './funnel/FunnelReport';
+import EventDataReport from './event-data/EventDataReport';
+
+const reports = {
+ funnel: FunnelReport,
+ 'event-data': EventDataReport,
+};
+
+export default function ReportDetails({ reportId, reportType }) {
+ const Report = reports[reportType];
+
+ return ;
+}
diff --git a/components/pages/reports/ReportHeader.js b/components/pages/reports/ReportHeader.js
new file mode 100644
index 00000000..394b1951
--- /dev/null
+++ b/components/pages/reports/ReportHeader.js
@@ -0,0 +1,89 @@
+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 '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 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 || 'Untitled' });
+ };
+
+ const handleDescriptionChange = description => {
+ updateReport({ description });
+ };
+
+ const Title = () => {
+ return (
+ <>
+ {icon}
+
+ >
+ );
+ };
+
+ return (
+
+
}>
+
+ {formatMessage(labels.save)}
+
+
+
+
+
+
+ );
+}
+
+export default ReportHeader;
diff --git a/components/pages/reports/ReportHeader.module.css b/components/pages/reports/ReportHeader.module.css
new file mode 100644
index 00000000..01e483a0
--- /dev/null
+++ b/components/pages/reports/ReportHeader.module.css
@@ -0,0 +1,3 @@
+.description {
+ color: var(--font-color300);
+}
diff --git a/components/pages/reports/ReportMenu.js b/components/pages/reports/ReportMenu.js
new file mode 100644
index 00000000..abfea6fe
--- /dev/null
+++ b/components/pages/reports/ReportMenu.js
@@ -0,0 +1,7 @@
+import styles from './reports.module.css';
+
+export function ReportMenu({ children }) {
+ return {children}
;
+}
+
+export default ReportMenu;
diff --git a/components/pages/reports/ReportTemplates.js b/components/pages/reports/ReportTemplates.js
new file mode 100644
index 00000000..7ed40af7
--- /dev/null
+++ b/components/pages/reports/ReportTemplates.js
@@ -0,0 +1,71 @@
+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 Nodes from 'assets/nodes.svg';
+import Lightbulb from 'assets/lightbulb.svg';
+import styles from './ReportTemplates.module.css';
+import { useMessages } from 'hooks';
+
+const reports = [
+ {
+ title: 'Event data',
+ description: 'Query your custom event data.',
+ url: '/reports/event-data',
+ icon: ,
+ },
+ {
+ title: 'Funnel',
+ description: 'Understand the conversion and drop-off rate of users.',
+ url: '/reports/funnel',
+ icon: ,
+ },
+ {
+ title: 'Insights',
+ description: 'Explore your data by applying segments and filters.',
+ url: '/reports/insights',
+ icon: ,
+ },
+];
+
+function ReportItem({ title, description, url, icon }) {
+ return (
+
+
+ {icon}
+ {title}
+
+
{description}
+
+
+
+
+
+
+ Create
+
+
+
+
+ );
+}
+
+export function ReportTemplates() {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+
+
+
+ {reports.map(({ title, description, url, icon }) => {
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+export default ReportTemplates;
diff --git a/components/pages/reports/ReportTemplates.module.css b/components/pages/reports/ReportTemplates.module.css
new file mode 100644
index 00000000..0cdcb835
--- /dev/null
+++ b/components/pages/reports/ReportTemplates.module.css
@@ -0,0 +1,32 @@
+.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;
+}
diff --git a/components/pages/reports/ReportsList.js b/components/pages/reports/ReportsList.js
new file mode 100644
index 00000000..255cb546
--- /dev/null
+++ b/components/pages/reports/ReportsList.js
@@ -0,0 +1,29 @@
+import Page from 'components/layout/Page';
+import PageHeader from 'components/layout/PageHeader';
+import Link from 'next/link';
+import { Button, Icon, Icons, Text } from 'react-basics';
+import { useMessages, useReports } from 'hooks';
+import ReportsTable from './ReportsTable';
+
+export function ReportsList() {
+ const { formatMessage, labels } = useMessages();
+ const { reports, error, isLoading } = useReports();
+
+ return (
+
+
+
+
+
+
+
+ {formatMessage(labels.createReport)}
+
+
+
+
+
+ );
+}
+
+export default ReportsList;
diff --git a/components/pages/reports/ReportsTable.js b/components/pages/reports/ReportsTable.js
new file mode 100644
index 00000000..bcc97204
--- /dev/null
+++ b/components/pages/reports/ReportsTable.js
@@ -0,0 +1,36 @@
+import Link from 'next/link';
+import { Button, Text, Icon, Icons } from 'react-basics';
+import SettingsTable from 'components/common/SettingsTable';
+import useMessages from 'hooks/useMessages';
+
+export function ReportsTable({ data = [] }) {
+ const { formatMessage, labels } = useMessages();
+
+ const columns = [
+ { name: 'name', label: formatMessage(labels.name) },
+ { name: 'description', label: formatMessage(labels.description) },
+ { name: 'type', label: formatMessage(labels.type) },
+ { name: 'action', label: ' ' },
+ ];
+
+ return (
+
+ {row => {
+ const { id } = row;
+
+ return (
+
+
+
+
+
+ {formatMessage(labels.view)}
+
+
+ );
+ }}
+
+ );
+}
+
+export default ReportsTable;
diff --git a/components/pages/reports/event-data/EventDataParameters.js b/components/pages/reports/event-data/EventDataParameters.js
new file mode 100644
index 00000000..c703f9bf
--- /dev/null
+++ b/components/pages/reports/event-data/EventDataParameters.js
@@ -0,0 +1,144 @@
+import { useContext, useRef } from 'react';
+import { useApi, useMessages } from '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 } from 'lib/constants';
+import BaseParameters from '../BaseParameters';
+import FieldAddForm from './FieldAddForm';
+import ParameterList from '../ParameterList';
+import Icons from 'components/icons';
+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 queryDisabled = !websiteId || !dateRange;
+ const { data, error } = useFields(websiteId, startDate, endDate);
+ const parametersSelected = websiteId && startDate && endDate;
+ const hasData = data?.length !== 0;
+
+ const parameterGroups = [
+ { label: formatMessage(labels.fields), type: 'fields' },
+ { label: formatMessage(labels.filters), type: 'filters' },
+ { label: formatMessage(labels.groupBy), type: 'groups' },
+ ];
+
+ const parameterData = {
+ fields,
+ filters,
+ groups,
+ };
+
+ const handleSubmit = values => {
+ runReport(values);
+ };
+
+ const handleAdd = (type, value) => {
+ const data = parameterData[type];
+ updateReport({ parameters: { [type]: data.concat(value) } });
+ };
+
+ const handleRemove = (type, index) => {
+ const data = [...parameterData[type]];
+ data.splice(index, 1);
+ updateReport({ parameters: { [type]: data } });
+ };
+
+ const AddButton = ({ type }) => {
+ return (
+
+
+
+
+
+ {(close, element) => {
+ return (
+ ({
+ name: eventKey,
+ type: DATA_TYPES[eventDataType],
+ }))}
+ element={element}
+ onAdd={handleAdd}
+ onClose={close}
+ />
+ );
+ }}
+
+
+ );
+ };
+
+ return (
+
+ );
+}
+
+export default EventDataParameters;
diff --git a/components/pages/reports/event-data/EventDataParameters.module.css b/components/pages/reports/event-data/EventDataParameters.module.css
new file mode 100644
index 00000000..435cb1f6
--- /dev/null
+++ b/components/pages/reports/event-data/EventDataParameters.module.css
@@ -0,0 +1,8 @@
+.parameter {
+ display: flex;
+ gap: 10px;
+}
+
+.op {
+ font-weight: bold;
+}
diff --git a/components/pages/reports/event-data/EventDataReport.js b/components/pages/reports/event-data/EventDataReport.js
new file mode 100644
index 00000000..b3358ecf
--- /dev/null
+++ b/components/pages/reports/event-data/EventDataReport.js
@@ -0,0 +1,26 @@
+import Report from '../Report';
+import ReportHeader from '../ReportHeader';
+import ReportMenu from '../ReportMenu';
+import ReportBody from '../ReportBody';
+import EventDataParameters from './EventDataParameters';
+import Nodes from 'assets/nodes.svg';
+import EventDataTable from './EventDataTable';
+
+const defaultParameters = {
+ type: 'event-data',
+ parameters: { fields: [], filters: [], groups: [] },
+};
+
+export default function EventDataReport({ reportId }) {
+ return (
+
+ } />
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/pages/reports/event-data/EventDataTable.js b/components/pages/reports/event-data/EventDataTable.js
new file mode 100644
index 00000000..ffe9fb3a
--- /dev/null
+++ b/components/pages/reports/event-data/EventDataTable.js
@@ -0,0 +1,20 @@
+import { useContext } from 'react';
+import DataTable from 'components/metrics/DataTable';
+import { useMessages } from 'hooks';
+import { ReportContext } from '../Report';
+
+export function EventDataTable() {
+ const { report } = useContext(ReportContext);
+ const { formatMessage, labels } = useMessages();
+
+ return (
+
+ );
+}
+
+export default EventDataTable;
diff --git a/components/pages/reports/event-data/FieldAddForm.js b/components/pages/reports/event-data/FieldAddForm.js
new file mode 100644
index 00000000..df71b370
--- /dev/null
+++ b/components/pages/reports/event-data/FieldAddForm.js
@@ -0,0 +1,36 @@
+import { useState } from 'react';
+import { createPortal } from 'react-dom';
+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 = [], type, element, onAdd, onClose }) {
+ const [selected, setSelected] = useState();
+
+ const handleSelect = value => {
+ if (type === 'groups') {
+ handleSave(value);
+ return;
+ }
+
+ setSelected(value);
+ };
+
+ const handleSave = value => {
+ onAdd(type, value);
+ onClose();
+ };
+
+ return createPortal(
+
+ {!selected && }
+ {selected && type === 'fields' && }
+ {selected && type === 'filters' && }
+ ,
+ document.body,
+ );
+}
+
+export default FieldAddForm;
diff --git a/components/pages/reports/event-data/FieldAddForm.module.css b/components/pages/reports/event-data/FieldAddForm.module.css
new file mode 100644
index 00000000..5c5aaa4f
--- /dev/null
+++ b/components/pages/reports/event-data/FieldAddForm.module.css
@@ -0,0 +1,38 @@
+.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;
+}
diff --git a/components/pages/reports/funnel/FunnelChart.js b/components/pages/reports/funnel/FunnelChart.js
new file mode 100644
index 00000000..7253c3fa
--- /dev/null
+++ b/components/pages/reports/funnel/FunnelChart.js
@@ -0,0 +1,63 @@
+import { useCallback, useContext, useMemo } from 'react';
+import { Loading } from 'react-basics';
+import useMessages from 'hooks/useMessages';
+import useTheme from '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, dataPoints } = model.tooltip;
+
+ if (!dataPoints?.length || !opacity) {
+ setTooltipPopup(null);
+ return;
+ }
+
+ setTooltipPopup(`${formatLongNumber(dataPoints[0].raw.y)} ${formatMessage(labels.visitors)}`);
+ }, []);
+
+ const datasets = useMemo(() => {
+ return [
+ {
+ label: formatMessage(labels.uniqueVisitors),
+ data: data,
+ borderWidth: 1,
+ ...colors.chart.visitors,
+ },
+ ];
+ }, [data]);
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+export default FunnelChart;
diff --git a/components/pages/reports/funnel/FunnelChart.module.css b/components/pages/reports/funnel/FunnelChart.module.css
new file mode 100644
index 00000000..9e1690b3
--- /dev/null
+++ b/components/pages/reports/funnel/FunnelChart.module.css
@@ -0,0 +1,3 @@
+.loading {
+ height: 300px;
+}
diff --git a/components/pages/reports/funnel/FunnelParameters.js b/components/pages/reports/funnel/FunnelParameters.js
new file mode 100644
index 00000000..ae498176
--- /dev/null
+++ b/components/pages/reports/funnel/FunnelParameters.js
@@ -0,0 +1,86 @@
+import { useContext, useRef } from 'react';
+import { useMessages } from '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';
+
+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 (
+
+
+
+
+
+ {(close, element) => {
+ return ;
+ }}
+
+
+ );
+ };
+
+ return (
+
+ );
+}
+
+export default FunnelParameters;
diff --git a/components/pages/reports/funnel/FunnelReport.js b/components/pages/reports/funnel/FunnelReport.js
new file mode 100644
index 00000000..7b4d8ece
--- /dev/null
+++ b/components/pages/reports/funnel/FunnelReport.js
@@ -0,0 +1,28 @@
+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';
+
+const defaultParameters = {
+ type: 'funnel',
+ parameters: { window: 60, urls: [] },
+};
+
+export default function FunnelReport({ reportId }) {
+ return (
+
+ } />
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/pages/reports/funnel/FunnelReport.module.css b/components/pages/reports/funnel/FunnelReport.module.css
new file mode 100644
index 00000000..aed66b74
--- /dev/null
+++ b/components/pages/reports/funnel/FunnelReport.module.css
@@ -0,0 +1,10 @@
+.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;
+}
diff --git a/components/pages/reports/funnel/FunnelTable.js b/components/pages/reports/funnel/FunnelTable.js
new file mode 100644
index 00000000..ff6bdfb5
--- /dev/null
+++ b/components/pages/reports/funnel/FunnelTable.js
@@ -0,0 +1,20 @@
+import { useContext } from 'react';
+import DataTable from 'components/metrics/DataTable';
+import { useMessages } from 'hooks';
+import { ReportContext } from '../Report';
+
+export function FunnelTable() {
+ const { report } = useContext(ReportContext);
+ const { formatMessage, labels } = useMessages();
+
+ return (
+
+ );
+}
+
+export default FunnelTable;
diff --git a/components/pages/reports/funnel/UrlAddForm.js b/components/pages/reports/funnel/UrlAddForm.js
new file mode 100644
index 00000000..0fb78b3d
--- /dev/null
+++ b/components/pages/reports/funnel/UrlAddForm.js
@@ -0,0 +1,51 @@
+import { useState } from 'react';
+import { useMessages } from 'hooks';
+import { Button, Form, FormRow, TextField, Flexbox } from 'react-basics';
+import styles from './UrlAddForm.module.css';
+import PopupForm from '../PopupForm';
+
+export function UrlAddForm({ defaultValue = '', element, onAdd, onClose }) {
+ const [url, setUrl] = useState(defaultValue);
+ const { formatMessage, labels } = useMessages();
+
+ const handleSave = () => {
+ onAdd(url);
+ setUrl('');
+ onClose();
+ };
+
+ const handleChange = e => {
+ setUrl(e.target.value);
+ };
+
+ const handleKeyDown = e => {
+ if (e.key === 'Enter') {
+ e.stopPropagation();
+ handleSave();
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+export default UrlAddForm;
diff --git a/components/pages/reports/funnel/UrlAddForm.module.css b/components/pages/reports/funnel/UrlAddForm.module.css
new file mode 100644
index 00000000..6a3e03b5
--- /dev/null
+++ b/components/pages/reports/funnel/UrlAddForm.module.css
@@ -0,0 +1,14 @@
+.form {
+ position: absolute;
+ background: var(--base50);
+ width: 300px;
+ padding: 30px;
+ margin-top: 10px;
+ border: 1px solid var(--base400);
+ border-radius: var(--border-radius);
+ box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1);
+}
+
+.input {
+ width: 100%;
+}
diff --git a/components/pages/reports/reports.module.css b/components/pages/reports/reports.module.css
new file mode 100644
index 00000000..6fa54281
--- /dev/null
+++ b/components/pages/reports/reports.module.css
@@ -0,0 +1,25 @@
+.container {
+ display: grid;
+ grid-template-rows: max-content 1fr;
+ grid-template-columns: max-content 1fr;
+}
+
+.header {
+ grid-row: 1 / 2;
+ grid-column: 1 / 3;
+ margin-bottom: 40px;
+}
+
+.menu {
+ width: 300px;
+ padding-right: 20px;
+ border-right: 1px solid var(--base300);
+ grid-row: 2/3;
+ grid-column: 1 / 2;
+}
+
+.body {
+ padding-left: 20px;
+ grid-row: 2/3;
+ grid-column: 2 / 3;
+}
diff --git a/components/pages/settings/profile/DateRangeSetting.js b/components/pages/settings/profile/DateRangeSetting.js
index 152aba1d..16db3c07 100644
--- a/components/pages/settings/profile/DateRangeSetting.js
+++ b/components/pages/settings/profile/DateRangeSetting.js
@@ -7,13 +7,19 @@ import useMessages from 'hooks/useMessages';
export function DateRangeSetting() {
const { formatMessage, labels } = useMessages();
const [dateRange, setDateRange] = useDateRange();
- const { startDate, endDate, value } = dateRange;
+ const { value } = dateRange;
+ const handleChange = value => setDateRange(value);
const handleReset = () => setDateRange(DEFAULT_DATE_RANGE);
return (
-
+
{formatMessage(labels.reset)}
);
diff --git a/components/pages/settings/profile/PasswordChangeButton.js b/components/pages/settings/profile/PasswordChangeButton.js
index 9aa6fdca..03bf74bc 100644
--- a/components/pages/settings/profile/PasswordChangeButton.js
+++ b/components/pages/settings/profile/PasswordChangeButton.js
@@ -1,11 +1,11 @@
-import { Button, Icon, Text, useToast, ModalTrigger, Modal } from 'react-basics';
+import { Button, Icon, Text, useToasts, ModalTrigger, Modal } from 'react-basics';
import PasswordEditForm from 'components/pages/settings/profile/PasswordEditForm';
import Icons from 'components/icons';
import useMessages from 'hooks/useMessages';
export function PasswordChangeButton() {
const { formatMessage, labels, messages } = useMessages();
- const { toast, showToast } = useToast();
+ const { showToast } = useToasts();
const handleSave = () => {
showToast({ message: formatMessage(messages.saved), variant: 'success' });
@@ -13,7 +13,6 @@ export function PasswordChangeButton() {
return (
<>
- {toast}
diff --git a/components/pages/settings/profile/ThemeSetting.js b/components/pages/settings/profile/ThemeSetting.js
index f4503268..54a8dac8 100644
--- a/components/pages/settings/profile/ThemeSetting.js
+++ b/components/pages/settings/profile/ThemeSetting.js
@@ -6,13 +6,13 @@ import Moon from 'assets/moon.svg';
import styles from './ThemeSetting.module.css';
export function ThemeSetting() {
- const [theme, setTheme] = useTheme();
+ const { theme, saveTheme } = useTheme();
return (
setTheme('light')}
+ onClick={() => saveTheme('light')}
>
@@ -20,7 +20,7 @@ export function ThemeSetting() {
setTheme('dark')}
+ onClick={() => saveTheme('dark')}
>
diff --git a/components/pages/settings/teams/TeamLeaveForm.js b/components/pages/settings/teams/TeamLeaveForm.js
index 9b61d4d9..954006ab 100644
--- a/components/pages/settings/teams/TeamLeaveForm.js
+++ b/components/pages/settings/teams/TeamLeaveForm.js
@@ -5,7 +5,7 @@ import useMessages from 'hooks/useMessages';
export function TeamLeaveForm({ teamId, userId, teamName, onSave, onClose }) {
const { formatMessage, labels, messages, FormattedMessage } = useMessages();
const { del, useMutation } = useApi();
- const { mutate, error, isLoading } = useMutation(() => del(`/team/${teamId}/users/${userId}`));
+ const { mutate, error, isLoading } = useMutation(() => del(`/teams/${teamId}/users/${userId}`));
const handleSubmit = async () => {
mutate(
@@ -22,7 +22,7 @@ export function TeamLeaveForm({ teamId, userId, teamName, onSave, onClose }) {
return (