diff --git a/components/common/FilterLink.js b/components/common/FilterLink.js index f91d4764..eb410719 100644 --- a/components/common/FilterLink.js +++ b/components/common/FilterLink.js @@ -6,13 +6,13 @@ import { Icon, Icons } from 'react-basics'; import styles from './FilterLink.module.css'; export default function FilterLink({ id, value, label, externalUrl }) { - const { resolve, query } = usePageQuery(); + const { resolveUrl, query } = usePageQuery(); const active = query[id] !== undefined; const selected = query[id] === value; return (
- + { - const measureEl = useRef(); - const [isOverflown, setIsOverflown] = useState(false); - - const measure = useCallback( - el => { - if (!el) return; - setIsOverflown(el.scrollWidth > el.clientWidth); - }, - [setIsOverflown], - ); - - // Do one measure on mount - useEffect(() => { - measure(measureEl.current); - }, [measure]); - - // Set up resize listener for subsequent measures - useEffect(() => { - if (!measureEl.current) return; - - // Destructure ref in case it changes out from under us - const el = measureEl.current; - - if ('ResizeObserver' in global) { - // Ideally, we have access to ResizeObservers - const observer = new ResizeObserver(() => { - measure(el); - }); - observer.observe(el); - return () => observer.unobserve(el); - } else { - // Otherwise, fall back to measuring on window resizes - const handler = () => measure(el); - - window.addEventListener('resize', handler, { passive: true }); - return () => window.removeEventListener('resize', handler, { passive: true }); - } - }); - - return ( - - {children} - {isOverflown && {children}} - - ); -}; - -export default OverflowText; diff --git a/components/common/OverflowText.module.css b/components/common/OverflowText.module.css deleted file mode 100644 index c2066631..00000000 --- a/components/common/OverflowText.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.root { - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} diff --git a/components/icons.ts b/components/icons.ts index 4868edc6..f3040bbd 100644 --- a/components/icons.ts +++ b/components/icons.ts @@ -4,6 +4,7 @@ import Bolt from 'assets/bolt.svg'; import Calendar from 'assets/calendar.svg'; import Clock from 'assets/clock.svg'; import Dashboard from 'assets/dashboard.svg'; +import Eye from 'assets/eye.svg'; import Gear from 'assets/gear.svg'; import Globe from 'assets/globe.svg'; import Lock from 'assets/lock.svg'; @@ -13,6 +14,7 @@ import Profile from 'assets/profile.svg'; import Sun from 'assets/sun.svg'; import User from 'assets/user.svg'; import Users from 'assets/users.svg'; +import Visitor from 'assets/visitor.svg'; const icons = { ...Icons, @@ -21,6 +23,7 @@ const icons = { Calendar, Clock, Dashboard, + Eye, Gear, Globe, Lock, @@ -30,6 +33,7 @@ const icons = { Sun, User, Users, + Visitor, }; export default icons; diff --git a/components/common/DateFilter.js b/components/input/DateFilter.js similarity index 51% rename from components/common/DateFilter.js rename to components/input/DateFilter.js index 77bab32f..a340fe48 100644 --- a/components/common/DateFilter.js +++ b/components/input/DateFilter.js @@ -1,68 +1,74 @@ -import { endOfYear, isSameDay } from 'date-fns'; import { useState } from 'react'; -import { Icon, Modal, Dropdown, Item } from 'react-basics'; -import { useIntl, defineMessages } from 'react-intl'; +import { Icon, Modal, Dropdown, Item, Text, Flexbox } from 'react-basics'; +import { useIntl } from 'react-intl'; +import { endOfYear, isSameDay } from 'date-fns'; import DatePickerForm from 'components/metrics/DatePickerForm'; import useLocale from 'hooks/useLocale'; -import { dateFormat } from 'lib/date'; -import Calendar from 'assets/calendar.svg'; +import { dateFormat, getDateRangeValues } from 'lib/date'; +import Icons from 'components/icons'; +import { labels } from 'components/messages'; +import useApi from 'hooks/useApi'; +import useDateRange from 'hooks/useDateRange'; -const messages = defineMessages({ - today: { id: 'label.today', defaultMessage: 'Today' }, - lastHours: { id: 'label.last-hours', defaultMessage: 'Last {x} hours' }, - yesterday: { id: 'label.yesterday', defaultMessage: 'Yesterday' }, - thisWeek: { id: 'label.this-week', defaultMessage: 'This week' }, - lastDays: { id: 'label.last-days', defaultMessage: 'Last {x} days' }, - thisMonth: { id: 'label.this-month', defaultMessage: 'This month' }, - thisYear: { id: 'label.this-year', defaultMessage: 'This year' }, - allTime: { id: 'label.all-time', defaultMessage: 'All time' }, - customRange: { id: 'label.custom-range', defaultMessage: 'Custom-range' }, -}); - -function DateFilter({ value, startDate, endDate, onChange, className }) { +function DateFilter({ websiteId, value, className }) { const { formatMessage } = useIntl(); + const { get } = useApi(); + const [dateRange, setDateRange] = useDateRange(websiteId); + const { startDate, endDate } = dateRange; const [showPicker, setShowPicker] = useState(false); + async function handleDateChange(value) { + if (value === 'all') { + const data = await get(`/websites/${websiteId}`); + + if (data) { + setDateRange({ value, ...getDateRangeValues(new Date(data.createdAt), Date.now()) }); + } + } else { + setDateRange(value); + } + } + const options = [ - { label: formatMessage(messages.today), value: '1day' }, + { label: formatMessage(labels.today), value: '1day' }, { - label: formatMessage(messages.lastHours, { x: 24 }), + label: formatMessage(labels.lastHours, { x: 24 }), value: '24hour', }, { - label: formatMessage(messages.yesterday), + label: formatMessage(labels.yesterday), value: '-1day', }, { - label: formatMessage(messages.thisWeek), + label: formatMessage(labels.thisWeek), value: '1week', divider: true, }, { - label: formatMessage(messages.lastDays, { x: 7 }), + label: formatMessage(labels.lastDays, { x: 7 }), value: '7day', }, { - label: formatMessage(messages.thisMonth), + label: formatMessage(labels.thisMonth), value: '1month', divider: true, }, { - label: formatMessage(messages.lastDays, { x: 30 }), + label: formatMessage(labels.lastDays, { x: 30 }), value: '30day', }, { - label: formatMessage(messages.lastDays, { x: 90 }), + label: formatMessage(labels.lastDays, { x: 90 }), value: '90day', }, - { label: formatMessage(messages.thisYear), value: '1year' }, + { label: formatMessage(labels.thisYear), value: '1year' }, { - label: formatMessage(messages.allTime), + label: formatMessage(labels.allTime), value: 'all', divider: true, }, { - label: formatMessage(messages.customRange), + label: formatMessage(labels.customRange), value: 'custom', divider: true, }, @@ -76,17 +82,17 @@ function DateFilter({ value, startDate, endDate, onChange, className }) { ); }; - const handleChange = async value => { + const handleChange = value => { if (value === 'custom') { setShowPicker(true); return; } - onChange(value); + handleDateChange(value); }; const handlePickerChange = value => { setShowPicker(false); - onChange(value); + handleDateChange(value); }; const handleClose = () => setShowPicker(false); @@ -98,9 +104,14 @@ function DateFilter({ value, startDate, endDate, onChange, className }) { items={options} renderValue={renderValue} value={value} + alignment="end" onChange={handleChange} > - {({ label, value }) => {label}} + {({ label, value, divider }) => ( + + {label} + + )} {showPicker && ( @@ -128,13 +139,15 @@ const CustomRange = ({ startDate, endDate, onClick }) => { } return ( - <> + - + - {dateFormat(startDate, 'd LLL y', locale)} - {!isSameDay(startDate, endDate) && ` — ${dateFormat(endDate, 'd LLL y', locale)}`} - + + {dateFormat(startDate, 'd LLL y', locale)} + {!isSameDay(startDate, endDate) && ` — ${dateFormat(endDate, 'd LLL y', locale)}`} + + ); }; diff --git a/components/input/RefreshButton.js b/components/input/RefreshButton.js index 635a6270..031e4ee6 100644 --- a/components/input/RefreshButton.js +++ b/components/input/RefreshButton.js @@ -1,22 +1,16 @@ -import { useState, useEffect, useCallback } from 'react'; import { useIntl } from 'react-intl'; -import { Button, Icon, Tooltip } from 'react-basics'; -import useStore from 'store/queries'; +import { LoadingButton, Icon, Tooltip } from 'react-basics'; import { setDateRange } from 'store/websites'; import useDateRange from 'hooks/useDateRange'; import Icons from 'components/icons'; import { labels } from 'components/messages'; -function RefreshButton({ websiteId }) { +function RefreshButton({ websiteId, isLoading }) { const { formatMessage } = useIntl(); const [dateRange] = useDateRange(websiteId); - const [loading, setLoading] = useState(false); - const selector = useCallback(state => state[`/websites/${websiteId}/stats`], [websiteId]); - const completed = useStore(selector); function handleClick() { - if (!loading && dateRange) { - setLoading(true); + if (!isLoading && dateRange) { if (/^\d+/.test(dateRange.value)) { setDateRange(websiteId, dateRange.value); } else { @@ -25,17 +19,13 @@ function RefreshButton({ websiteId }) { } } - useEffect(() => { - setLoading(false); - }, [completed]); - return ( - + ); } diff --git a/components/input/WebsiteSelect.js b/components/input/WebsiteSelect.js new file mode 100644 index 00000000..2a1a4e17 --- /dev/null +++ b/components/input/WebsiteSelect.js @@ -0,0 +1,28 @@ +import { useIntl } from 'react-intl'; +import { Dropdown, Item } from 'react-basics'; +import { labels } from 'components/messages'; +import useApi from 'hooks/useApi'; + +export default function WebsiteSelect({ websiteId, onSelect }) { + const { formatMessage } = useIntl(); + const { get, useQuery } = useApi(); + const { data } = useQuery(['websites:me'], () => get('/me/websites')); + + const renderValue = value => { + return data?.find(({ id }) => id === value)?.name; + }; + + return ( + + {({ id, name }) => {name}} + + ); +} diff --git a/components/layout/AppLayout.js b/components/layout/AppLayout.js index d89c34b9..ad744fab 100644 --- a/components/layout/AppLayout.js +++ b/components/layout/AppLayout.js @@ -2,13 +2,15 @@ import { Container } from 'react-basics'; import Head from 'next/head'; import NavBar from 'components/layout/NavBar'; import useRequireLogin from 'hooks/useRequireLogin'; +import useConfig from 'hooks/useConfig'; import { UI_LAYOUT_BODY } from 'lib/constants'; import styles from './AppLayout.module.css'; export default function AppLayout({ title, children }) { const { user } = useRequireLogin(); + const config = useConfig(); - if (!user) { + if (!user || !config) { return null; } diff --git a/components/layout/Grid.js b/components/layout/Grid.js new file mode 100644 index 00000000..0276063b --- /dev/null +++ b/components/layout/Grid.js @@ -0,0 +1,13 @@ +import { Row, Column } from 'react-basics'; +import classNames from 'classnames'; +import styles from './Grid.module.css'; + +export function GridRow(props) { + const { className, ...otherProps } = props; + return ; +} + +export function GridColumn(props) { + const { className, ...otherProps } = props; + return ; +} diff --git a/components/layout/Grid.module.css b/components/layout/Grid.module.css new file mode 100644 index 00000000..20df43c9 --- /dev/null +++ b/components/layout/Grid.module.css @@ -0,0 +1,35 @@ +.col { + display: flex; + flex-direction: column; + padding: 20px; +} + +.row { + border-top: 1px solid var(--base300); + min-height: 430px; +} + +.row > .col { + border-left: 1px solid var(--base300); +} + +.row > .col:first-child { + border-left: 0; + padding-left: 0; +} + +.row > .col:last-child { + padding-right: 0; +} + +@media only screen and (max-width: 992px) { + .row { + border: 0; + } + + .row > .col { + border-top: 1px solid var(--base300); + border-left: 0; + padding: 20px 0; + } +} diff --git a/components/layout/NavBar.js b/components/layout/NavBar.js index 07a72bc1..80307568 100644 --- a/components/layout/NavBar.js +++ b/components/layout/NavBar.js @@ -10,9 +10,11 @@ import { labels } from 'components/messages'; import useUser from 'hooks/useUser'; import NavGroup from './NavGroup'; import styles from './NavBar.module.css'; +import useConfig from 'hooks/useConfig'; export default function NavBar() { const { user } = useUser(); + const { cloudMode } = useConfig(); const { formatMessage } = useIntl(); const [minimized, setMinimized] = useState(false); const tooltipPosition = minimized ? 'right' : 'top'; @@ -24,13 +26,21 @@ export default function NavBar() { ]; const settings = [ - { label: formatMessage(labels.websites), url: '/settings/websites', icon: }, + !cloudMode && { + label: formatMessage(labels.websites), + url: '/settings/websites', + icon: , + }, user?.isAdmin && { label: formatMessage(labels.users), url: '/settings/users', icon: , }, - { label: formatMessage(labels.teams), url: '/settings/teams', icon: }, + !cloudMode && { + label: formatMessage(labels.teams), + url: '/settings/teams', + icon: , + }, { label: formatMessage(labels.profile), url: '/settings/profile', icon: }, ].filter(n => n); @@ -53,7 +63,7 @@ export default function NavBar() {
- + {!cloudMode && }
diff --git a/components/layout/PageHeader.module.css b/components/layout/PageHeader.module.css index c48749f7..511f1091 100644 --- a/components/layout/PageHeader.module.css +++ b/components/layout/PageHeader.module.css @@ -19,7 +19,7 @@ .title { display: flex; align-items: center; - font-size: 18px; - font-weight: bold; + font-size: 24px; + font-weight: 700; gap: 20px; } diff --git a/components/messages.js b/components/messages.js index c29432b1..cc1d0e86 100644 --- a/components/messages.js +++ b/components/messages.js @@ -81,9 +81,23 @@ export const labels = defineMessages({ visitors: { id: 'label.visitors', defaultMessage: 'Visitors' }, filterCombined: { id: 'label.filter-combined', defaultMessage: 'Combined' }, filterRaw: { id: 'label.filter-raw', defaultMessage: 'Raw' }, - views: { id: 'label.views', defaultMessage: 'View' }, + views: { id: 'label.views', defaultMessage: 'Views' }, none: { id: 'label.none', defaultMessage: 'None' }, clearAll: { id: 'label.clear-all', defaultMessage: 'Clear all' }, + today: { id: 'label.today', defaultMessage: 'Today' }, + lastHours: { id: 'label.last-hours', defaultMessage: 'Last {x} hours' }, + yesterday: { id: 'label.yesterday', defaultMessage: 'Yesterday' }, + thisWeek: { id: 'label.this-week', defaultMessage: 'This week' }, + lastDays: { id: 'label.last-days', defaultMessage: 'Last {x} days' }, + thisMonth: { id: 'label.this-month', defaultMessage: 'This month' }, + thisYear: { id: 'label.this-year', defaultMessage: 'This year' }, + allTime: { id: 'label.all-time', defaultMessage: 'All time' }, + customRange: { id: 'label.custom-range', defaultMessage: 'Custom-range' }, + selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' }, + all: { id: 'label.all', defaultMessage: 'All' }, + sessions: { id: 'label.sessions', defaultMessage: 'Sessions' }, + pageNotFound: { id: 'message.page-not-found', defaultMessage: 'Page not found' }, + logs: { id: 'label.activity-log', defaultMessage: 'Activity log' }, }); export const messages = defineMessages({ @@ -155,6 +169,14 @@ export const messages = defineMessages({ id: 'message.team-not-found', defaultMessage: 'Team not found.', }, + visitorLog: { + id: 'message.visitor-log', + defaultMessage: 'Visitor from {country} using {browser} on {os} {device}', + }, + eventLog: { + id: 'message.event-log', + defaultMessage: '{event} on {url}', + }, }); export const devices = defineMessages({ diff --git a/components/metrics/ActiveUsers.js b/components/metrics/ActiveUsers.js index 664e23a9..cb3afe80 100644 --- a/components/metrics/ActiveUsers.js +++ b/components/metrics/ActiveUsers.js @@ -13,6 +13,7 @@ export default function ActiveUsers({ websiteId, value, refetchInterval = 60000 () => get(`/websites/${websiteId}/active`), { refetchInterval, + enabled: !!websiteId, }, ); diff --git a/components/metrics/DataTable.js b/components/metrics/DataTable.js index 9a2de075..76272808 100644 --- a/components/metrics/DataTable.js +++ b/components/metrics/DataTable.js @@ -8,7 +8,7 @@ import { formatNumber, formatLongNumber } from 'lib/format'; import styles from './DataTable.module.css'; export default function DataTable({ - data, + data = [], title, metric, className, diff --git a/components/metrics/DatePickerForm.js b/components/metrics/DatePickerForm.js index 17c7b5e5..5235d339 100644 --- a/components/metrics/DatePickerForm.js +++ b/components/metrics/DatePickerForm.js @@ -42,7 +42,7 @@ export default function DatePickerForm({ return (
- + diff --git a/components/metrics/EventDataForm.js b/components/metrics/EventDataForm.js index dbcc5917..aa350415 100644 --- a/components/metrics/EventDataForm.js +++ b/components/metrics/EventDataForm.js @@ -1,6 +1,6 @@ import { useMutation } from '@tanstack/react-query'; import classNames from 'classnames'; -import DateFilter from 'components/common/DateFilter'; +import DateFilter from 'components/input/DateFilter'; import DataTable from 'components/metrics/DataTable'; import FilterTags from 'components/metrics/FilterTags'; import useApi from 'hooks/useApi'; diff --git a/components/metrics/FilterTags.js b/components/metrics/FilterTags.js index 0e68504b..37f5981c 100644 --- a/components/metrics/FilterTags.js +++ b/components/metrics/FilterTags.js @@ -1,26 +1,39 @@ import { useIntl } from 'react-intl'; -import classNames from 'classnames'; import { safeDecodeURI } from 'next-basics'; import { Button, Icon, Icons, Text } from 'react-basics'; import { labels } from 'components/messages'; +import usePageQuery from 'hooks/usePageQuery'; import styles from './FilterTags.module.css'; -export default function FilterTags({ className, params, onClick }) { +export default function FilterTags({ websiteId, params, onClick }) { const { formatMessage } = useIntl(); + const { + router, + resolveUrl, + query: { view }, + } = usePageQuery(); if (Object.keys(params).filter(key => params[key]).length === 0) { return null; } + function handleCloseFilter(param) { + if (param === null) { + router.push(`/websites/${websiteId}/?view=${view}`); + } else { + router.push(resolveUrl({ [param]: undefined })); + } + } + return ( -
+
{Object.keys(params).map(key => { if (!params[key]) { return null; } return (
-
); })} -
} + + {filter === FILTER_REFERRERS && ( } - metric={} + title={formatMessage(labels.referrers)} + metric={formatMessage(labels.views)} renderLabel={renderLink} data={referrers} /> )} {filter === FILTER_PAGES && ( } - metric={} + title={formatMessage(labels.pages)} + metric={formatMessage(labels.views)} renderLabel={renderLink} data={pages} /> diff --git a/components/pages/settings/profile/DateRangeSetting.js b/components/pages/settings/profile/DateRangeSetting.js index a5139cb3..5eca8cc5 100644 --- a/components/pages/settings/profile/DateRangeSetting.js +++ b/components/pages/settings/profile/DateRangeSetting.js @@ -1,5 +1,5 @@ import { useIntl } from 'react-intl'; -import DateFilter from 'components/common/DateFilter'; +import DateFilter from 'components/input/DateFilter'; import { Button, Flexbox } from 'react-basics'; import useDateRange from 'hooks/useDateRange'; import { DEFAULT_DATE_RANGE } from 'lib/constants'; diff --git a/components/pages/settings/profile/ProfileSettings.js b/components/pages/settings/profile/ProfileSettings.js index 9d22cfae..d302b6b2 100644 --- a/components/pages/settings/profile/ProfileSettings.js +++ b/components/pages/settings/profile/ProfileSettings.js @@ -4,14 +4,16 @@ import PageHeader from 'components/layout/PageHeader'; import ProfileDetails from './ProfileDetails'; import PasswordChangeButton from './PasswordChangeButton'; import { labels } from 'components/messages'; +import useConfig from 'hooks/useConfig'; export default function ProfileSettings() { const { formatMessage } = useIntl(); + const { cloudMode } = useConfig(); return ( - + {!cloudMode && } diff --git a/components/pages/settings/teams/TeamsList.js b/components/pages/settings/teams/TeamsList.js index a2ed628c..b48e9971 100644 --- a/components/pages/settings/teams/TeamsList.js +++ b/components/pages/settings/teams/TeamsList.js @@ -65,7 +65,7 @@ export default function TeamsList() { return ( {toast} - + {hasData && ( {joinButton} diff --git a/components/pages/settings/websites/WebsiteReset.js b/components/pages/settings/websites/WebsiteData.js similarity index 61% rename from components/pages/settings/websites/WebsiteReset.js rename to components/pages/settings/websites/WebsiteData.js index d742d113..64979fe4 100644 --- a/components/pages/settings/websites/WebsiteReset.js +++ b/components/pages/settings/websites/WebsiteData.js @@ -1,10 +1,10 @@ -import { Button, Form, FormRow, Modal, ModalTrigger } from 'react-basics'; +import { Button, Modal, ModalTrigger, ActionForm } from 'react-basics'; import { useIntl } from 'react-intl'; import WebsiteDeleteForm from 'components/pages/settings/websites/WebsiteDeleteForm'; import WebsiteResetForm from 'components/pages/settings/websites/WebsiteResetForm'; import { labels, messages } from 'components/messages'; -export default function WebsiteReset({ websiteId, onSave }) { +export default function WebsiteData({ websiteId, onSave }) { const { formatMessage } = useIntl(); const handleReset = async () => { @@ -16,29 +16,33 @@ export default function WebsiteReset({ websiteId, onSave }) { }; return ( -
- -

{formatMessage(messages.resetWebsiteWarning)}

+ <> + - + {close => ( )} -
- -

{formatMessage(messages.deleteWebsiteWarning)}

+ + - + {close => ( )} -
-
+ + ); } diff --git a/components/pages/settings/websites/WebsiteSettings.js b/components/pages/settings/websites/WebsiteSettings.js index e61c699d..02693c72 100644 --- a/components/pages/settings/websites/WebsiteSettings.js +++ b/components/pages/settings/websites/WebsiteSettings.js @@ -6,7 +6,7 @@ import Link from 'next/link'; import Page from 'components/layout/Page'; import PageHeader from 'components/layout/PageHeader'; import WebsiteEditForm from 'components/pages/settings/websites/WebsiteEditForm'; -import WebsiteReset from 'components/pages/settings/websites/WebsiteReset'; +import WebsiteData from 'components/pages/settings/websites/WebsiteData'; import TrackingCode from 'components/pages/settings/websites/TrackingCode'; import ShareUrl from 'components/pages/settings/websites/ShareUrl'; import useApi from 'hooks/useApi'; @@ -59,8 +59,8 @@ export default function WebsiteSettings({ websiteId }) { } > - -
+ +