diff --git a/assets/bolt.svg b/assets/bolt.svg new file mode 100644 index 00000000..4654a1eb --- /dev/null +++ b/assets/bolt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/eye.svg b/assets/eye.svg new file mode 100644 index 00000000..09c93453 --- /dev/null +++ b/assets/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/visitor.svg b/assets/visitor.svg new file mode 100644 index 00000000..591873a5 --- /dev/null +++ b/assets/visitor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/common/CopyButton.js b/components/common/CopyButton.js index 399da90d..460c68ac 100644 --- a/components/common/CopyButton.js +++ b/components/common/CopyButton.js @@ -3,7 +3,7 @@ import Button from './Button'; import { FormattedMessage } from 'react-intl'; const defaultText = ( - + ); export default function CopyButton({ element, ...props }) { diff --git a/components/common/Dot.js b/components/common/Dot.js new file mode 100644 index 00000000..3f424820 --- /dev/null +++ b/components/common/Dot.js @@ -0,0 +1,15 @@ +import React from 'react'; +import classNames from 'classnames'; +import styles from './Dot.module.css'; + +export default function Dot({ color, size, className }) { + return ( +
+ ); +} diff --git a/components/common/Dot.module.css b/components/common/Dot.module.css new file mode 100644 index 00000000..9081dc5c --- /dev/null +++ b/components/common/Dot.module.css @@ -0,0 +1,17 @@ +.dot { + background: var(--green400); + width: 10px; + height: 10px; + border-radius: 100%; + margin-right: 10px; +} + +.dot.small { + width: 8px; + height: 8px; +} + +.dot.large { + width: 16px; + height: 16px; +} diff --git a/components/common/EmptyPlaceholder.js b/components/common/EmptyPlaceholder.js index a7b0720c..26a9fcbf 100644 --- a/components/common/EmptyPlaceholder.js +++ b/components/common/EmptyPlaceholder.js @@ -6,7 +6,7 @@ import styles from './EmptyPlaceholder.module.css'; export default function EmptyPlaceholder({ msg, children }) { return (
- } size="xlarge" /> + } size="xlarge" />

{msg}

{children}
diff --git a/components/common/EmptyPlaceholder.module.css b/components/common/EmptyPlaceholder.module.css index 7d2a93a5..58332566 100644 --- a/components/common/EmptyPlaceholder.module.css +++ b/components/common/EmptyPlaceholder.module.css @@ -5,3 +5,7 @@ align-items: center; min-height: 600px; } + +.icon { + margin-bottom: 30px; +} diff --git a/components/common/FilterButtons.js b/components/common/FilterButtons.js new file mode 100644 index 00000000..5b898bf4 --- /dev/null +++ b/components/common/FilterButtons.js @@ -0,0 +1,11 @@ +import React from 'react'; +import ButtonLayout from 'components/layout/ButtonLayout'; +import ButtonGroup from './ButtonGroup'; + +export default function FilterButtons({ buttons, selected, onClick }) { + return ( + + + + ); +} diff --git a/components/common/RefreshButton.js b/components/common/RefreshButton.js index af754a9c..b1b80a83 100644 --- a/components/common/RefreshButton.js +++ b/components/common/RefreshButton.js @@ -12,7 +12,7 @@ export default function RefreshButton({ websiteId }) { const dispatch = useDispatch(); const [dateRange] = useDateRange(websiteId); const [loading, setLoading] = useState(false); - const completed = useSelector(state => state.queries[`/api/website/${websiteId}/metrics`]); + const completed = useSelector(state => state.queries[`/api/website/${websiteId}/stats`]); function handleClick() { if (dateRange) { @@ -28,7 +28,7 @@ export default function RefreshButton({ websiteId }) { return (
diff --git a/components/forms/AccountEditForm.js b/components/forms/AccountEditForm.js index ccce3463..63949f00 100644 --- a/components/forms/AccountEditForm.js +++ b/components/forms/AccountEditForm.js @@ -70,10 +70,10 @@ export default function AccountEditForm({ values, onSave, onClose }) { {message} diff --git a/components/forms/ChangePasswordForm.js b/components/forms/ChangePasswordForm.js index c41b6e6b..d96a1336 100644 --- a/components/forms/ChangePasswordForm.js +++ b/components/forms/ChangePasswordForm.js @@ -85,10 +85,10 @@ export default function ChangePasswordForm({ values, onSave, onClose }) { {message} diff --git a/components/forms/DatePickerForm.js b/components/forms/DatePickerForm.js index 673cd7bd..9669f741 100644 --- a/components/forms/DatePickerForm.js +++ b/components/forms/DatePickerForm.js @@ -33,11 +33,11 @@ export default function DatePickerForm({ const buttons = [ { - label: , + label: , value: FILTER_DAY, }, { - label: , + label: , value: FILTER_RANGE, }, ]; @@ -72,10 +72,10 @@ export default function DatePickerForm({ diff --git a/components/forms/DeleteForm.js b/components/forms/DeleteForm.js index 689cd1fc..e1b4d9ca 100644 --- a/components/forms/DeleteForm.js +++ b/components/forms/DeleteForm.js @@ -82,10 +82,10 @@ export default function DeleteForm({ values, onSave, onClose }) { variant="danger" disabled={props.values.confirmation !== CONFIRMATION_WORD} > - + {message} diff --git a/components/forms/LoginForm.js b/components/forms/LoginForm.js index 3866f240..c89287a6 100644 --- a/components/forms/LoginForm.js +++ b/components/forms/LoginForm.js @@ -83,7 +83,7 @@ export default function LoginForm() { {message} diff --git a/components/forms/ShareUrlForm.js b/components/forms/ShareUrlForm.js index ea162f67..dbb1b656 100644 --- a/components/forms/ShareUrlForm.js +++ b/components/forms/ShareUrlForm.js @@ -30,7 +30,7 @@ export default function TrackingCodeForm({ values, onClose }) { diff --git a/components/forms/TrackingCodeForm.js b/components/forms/TrackingCodeForm.js index 1f44f835..a8d5a344 100644 --- a/components/forms/TrackingCodeForm.js +++ b/components/forms/TrackingCodeForm.js @@ -29,7 +29,7 @@ export default function TrackingCodeForm({ values, onClose }) { diff --git a/components/forms/WebsiteEditForm.js b/components/forms/WebsiteEditForm.js index 3432c2a4..dc87999e 100644 --- a/components/forms/WebsiteEditForm.js +++ b/components/forms/WebsiteEditForm.js @@ -91,10 +91,10 @@ export default function WebsiteEditForm({ values, onSave, onClose }) { {message} diff --git a/components/layout/Footer.js b/components/layout/Footer.js index 6fd0a46c..73e010bc 100644 --- a/components/layout/Footer.js +++ b/components/layout/Footer.js @@ -24,7 +24,9 @@ export default function Footer() { }} /> -
{`v${current}`}
+
+ {`v${current}`} +
); diff --git a/components/layout/GridLayout.js b/components/layout/GridLayout.js new file mode 100644 index 00000000..1d3170d6 --- /dev/null +++ b/components/layout/GridLayout.js @@ -0,0 +1,31 @@ +import React from 'react'; +import classNames from 'classnames'; +import styles from './GridLayout.module.css'; + +export default function GridLayout({ className, children }) { + return
{children}
; +} + +export const GridRow = ({ className, children }) => { + return
{children}
; +}; + +export const GridColumn = ({ xs, sm, md, lg, xl, className, children }) => { + const classes = []; + + classes.push(xs ? `col-${xs}` : 'col-12'); + + if (sm) { + classes.push(`col-sm-${sm}`); + } + if (md) { + classes.push(`col-md-${md}`); + } + if (lg) { + classes.push(`col-lg-${lg}`); + } + if (xl) { + classes.push(`col-lg-${xl}`); + } + return
{children}
; +}; diff --git a/components/layout/GridLayout.module.css b/components/layout/GridLayout.module.css new file mode 100644 index 00000000..f17c195e --- /dev/null +++ b/components/layout/GridLayout.module.css @@ -0,0 +1,40 @@ +.grid { + display: flex; + flex-direction: column; +} + +.col { + display: flex; + flex-direction: column; +} + +.row { + border-top: 1px solid var(--gray300); + min-height: 430px; +} + +.row > .col { + border-left: 1px solid var(--gray300); + padding: 20px; +} + +.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(--gray300); + border-left: 0; + padding: 0; + } +} diff --git a/components/layout/Header.js b/components/layout/Header.js index c48fdd11..cc8baae3 100644 --- a/components/layout/Header.js +++ b/components/layout/Header.js @@ -30,6 +30,9 @@ export default function Header() { + + + diff --git a/components/metrics/ActiveUsers.js b/components/metrics/ActiveUsers.js index 3d7b7001..6dabd3d5 100644 --- a/components/metrics/ActiveUsers.js +++ b/components/metrics/ActiveUsers.js @@ -1,11 +1,18 @@ import React, { useMemo } from 'react'; +import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; import useFetch from 'hooks/useFetch'; +import Dot from 'components/common/Dot'; +import { TOKEN_HEADER } from 'lib/constants'; +import useShareToken from 'hooks/useShareToken'; import styles from './ActiveUsers.module.css'; -import { FormattedMessage } from 'react-intl'; -export default function ActiveUsers({ websiteId, token, className }) { - const { data } = useFetch(`/api/website/${websiteId}/active`, { token }, { interval: 60000 }); +export default function ActiveUsers({ websiteId, className }) { + const shareToken = useShareToken(); + const { data } = useFetch(`/api/website/${websiteId}/active`, { + interval: 60000, + headers: { [TOKEN_HEADER]: shareToken?.token }, + }); const count = useMemo(() => { return data?.[0]?.x || 0; }, [data]); @@ -16,7 +23,7 @@ export default function ActiveUsers({ websiteId, token, className }) { return (
-
+
1 ? formatLongNumber(label) : label; + return +label > 1000 ? formatLongNumber(label) : label; } function renderTooltip(model) { diff --git a/components/metrics/BrowsersTable.js b/components/metrics/BrowsersTable.js index 97f9bfbd..12c1087b 100644 --- a/components/metrics/BrowsersTable.js +++ b/components/metrics/BrowsersTable.js @@ -3,15 +3,14 @@ import { FormattedMessage } from 'react-intl'; import MetricsTable from './MetricsTable'; import { browserFilter } from 'lib/filters'; -export default function BrowsersTable({ websiteId, token, limit }) { +export default function BrowsersTable({ websiteId, ...props }) { return ( } type="browser" metric={} websiteId={websiteId} - token={token} - limit={limit} dataFilter={browserFilter} /> ); diff --git a/components/metrics/CountriesTable.js b/components/metrics/CountriesTable.js index d562b464..59d17dfb 100644 --- a/components/metrics/CountriesTable.js +++ b/components/metrics/CountriesTable.js @@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl'; import useCountryNames from 'hooks/useCountryNames'; import useLocale from 'hooks/useLocale'; -export default function CountriesTable({ websiteId, token, limit, onDataLoad = () => {} }) { +export default function CountriesTable({ websiteId, onDataLoad, ...props }) { const [locale] = useLocale(); const countryNames = useCountryNames(locale); @@ -15,13 +15,12 @@ export default function CountriesTable({ websiteId, token, limit, onDataLoad = ( return ( } type="country" metric={} websiteId={websiteId} - token={token} - limit={limit} - onDataLoad={data => onDataLoad(percentFilter(data))} + onDataLoad={data => onDataLoad?.(percentFilter(data))} renderLabel={renderLabel} /> ); diff --git a/components/metrics/DataTable.js b/components/metrics/DataTable.js new file mode 100644 index 00000000..5cf9ddb4 --- /dev/null +++ b/components/metrics/DataTable.js @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import { FixedSizeList } from 'react-window'; +import { useSpring, animated, config } from 'react-spring'; +import classNames from 'classnames'; +import NoData from 'components/common/NoData'; +import { formatNumber, formatLongNumber } from 'lib/format'; +import styles from './DataTable.module.css'; + +export default function DataTable({ + data, + title, + metric, + className, + renderLabel, + height, + animate = true, + virtualize = false, +}) { + const [format, setFormat] = useState(true); + const formatFunc = format ? formatLongNumber : formatNumber; + + const handleSetFormat = () => setFormat(state => !state); + + const getRow = row => { + const { x: label, y: value, z: percent } = row; + + return ( + + ); + }; + + const Row = ({ index, style }) => { + return
{getRow(data[index])}
; + }; + + return ( +
+
+
{title}
+
+ {metric} +
+
+
+ {data?.length === 0 && } + {virtualize && data.length > 0 ? ( + + {Row} + + ) : ( + data.map(row => getRow(row)) + )} +
+
+ ); +} + +const AnimatedRow = ({ label, value = 0, percent, animate, format, onClick }) => { + const props = useSpring({ + width: percent, + y: value, + from: { width: 0, y: 0 }, + config: animate ? config.default : { duration: 0 }, + }); + + return ( +
+
{label}
+
+ {props.y?.interpolate(format)} +
+
+ `${n}%`) }} + /> + + {props.width.interpolate(n => `${n.toFixed(0)}%`)} + +
+
+ ); +}; diff --git a/components/metrics/DataTable.module.css b/components/metrics/DataTable.module.css new file mode 100644 index 00000000..79a60577 --- /dev/null +++ b/components/metrics/DataTable.module.css @@ -0,0 +1,95 @@ +.table { + position: relative; + font-size: var(--font-size-small); + display: flex; + flex-direction: column; + flex: 1; +} + +.body { + position: relative; + overflow: auto; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + line-height: 40px; +} + +.title { + display: flex; + font-weight: 600; + font-size: var(--font-size-normal); +} + +.metric { + font-size: var(--font-size-small); + font-weight: 600; + text-align: center; + width: 100px; + cursor: pointer; +} + +.row { + position: relative; + height: 30px; + line-height: 30px; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; + overflow: hidden; +} + +.label { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + flex: 2; +} + +.label a { + color: inherit; + text-decoration: none; +} + +.label a:hover { + color: var(--primary400); +} + +.label:empty { + color: #b3b3b3; +} + +.label:empty:before { + content: 'Unknown'; +} + +.value { + width: 50px; + text-align: right; + margin-right: 10px; + font-weight: 600; + cursor: pointer; +} + +.percent { + position: relative; + width: 50px; + color: var(--gray600); + border-left: 1px solid var(--gray600); + padding-left: 10px; + z-index: 1; +} + +.bar { + position: absolute; + top: 0; + left: 0; + height: 30px; + opacity: 0.1; + background: var(--primary400); + z-index: -1; +} diff --git a/components/metrics/DevicesTable.js b/components/metrics/DevicesTable.js index 7d87d1c1..52b6b5fc 100644 --- a/components/metrics/DevicesTable.js +++ b/components/metrics/DevicesTable.js @@ -4,15 +4,14 @@ import { deviceFilter } from 'lib/filters'; import { FormattedMessage } from 'react-intl'; import { getDeviceMessage } from 'components/messages'; -export default function DevicesTable({ websiteId, token, limit }) { +export default function DevicesTable({ websiteId, ...props }) { return ( } type="device" metric={} websiteId={websiteId} - token={token} - limit={limit} dataFilter={deviceFilter} renderLabel={({ x }) => getDeviceMessage(x)} /> diff --git a/components/metrics/EventsChart.js b/components/metrics/EventsChart.js index 113c6f56..9a5827b2 100644 --- a/components/metrics/EventsChart.js +++ b/components/metrics/EventsChart.js @@ -5,26 +5,31 @@ import { getDateArray, getDateLength } from 'lib/date'; import useFetch from 'hooks/useFetch'; import useDateRange from 'hooks/useDateRange'; import useTimezone from 'hooks/useTimezone'; -import { EVENT_COLORS } from 'lib/constants'; -import usePageQuery from '../../hooks/usePageQuery'; +import usePageQuery from 'hooks/usePageQuery'; +import useShareToken from 'hooks/useShareToken'; +import { EVENT_COLORS, TOKEN_HEADER } from 'lib/constants'; -export default function EventsChart({ websiteId, token }) { +export default function EventsChart({ websiteId, className, token }) { const [dateRange] = useDateRange(websiteId); const { startDate, endDate, unit, modified } = dateRange; const [timezone] = useTimezone(); const { query } = usePageQuery(); + const shareToken = useShareToken(); const { data } = useFetch( `/api/website/${websiteId}/events`, { - start_at: +startDate, - end_at: +endDate, - unit, - tz: timezone, - url: query.url, - token, + params: { + start_at: +startDate, + end_at: +endDate, + unit, + tz: timezone, + url: query.url, + token, + }, + headers: { [TOKEN_HEADER]: shareToken?.token }, }, - { update: [modified] }, + [modified], ); const datasets = useMemo(() => { if (!data) return []; @@ -44,7 +49,7 @@ export default function EventsChart({ websiteId, token }) { }); return Object.keys(map).map((key, index) => { - const color = tinycolor(EVENT_COLORS[index]); + const color = tinycolor(EVENT_COLORS[index % EVENT_COLORS.length]); return { label: key, data: map[key], @@ -77,6 +82,7 @@ export default function EventsChart({ websiteId, token }) { return ( } type="event" metric={} websiteId={websiteId} - token={token} - limit={limit} renderLabel={({ x }) =>