Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
Brian Cao 2023-05-25 16:46:39 -07:00
commit 4c06487b11
26 changed files with 347 additions and 391 deletions

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Tooltip } from 'react-basics'; import { Tooltip } from 'react-basics';
import styles from './HoverTooltip.module.css'; import styles from './HoverTooltip.module.css';
export function HoverTooltip({ tooltip }) { export function HoverTooltip({ children }) {
const [position, setPosition] = useState({ x: -1000, y: -1000 }); const [position, setPosition] = useState({ x: -1000, y: -1000 });
useEffect(() => { useEffect(() => {
@ -18,8 +18,8 @@ export function HoverTooltip({ tooltip }) {
}, []); }, []);
return ( return (
<div className={styles.tooltip} style={{ left: position.x, top: position.y }}> <div className={styles.tooltip} style={{ left: position.x, top: position.y - 16 }}>
<Tooltip position="top" action="none" label={tooltip} /> <Tooltip position="top" action="none" label={children} />
</div> </div>
); );
} }

View File

@ -15,7 +15,7 @@ import styles from './WorldMap.module.css';
export function WorldMap({ data, className }) { export function WorldMap({ data, className }) {
const { basePath } = useRouter(); const { basePath } = useRouter();
const [tooltip, setTooltip] = useState(); const [tooltip, setTooltip] = useState();
const [theme] = useTheme(); const { theme } = useTheme();
const colors = useMemo( const colors = useMemo(
() => ({ () => ({
baseColor: THEME_COLORS[theme].primary, baseColor: THEME_COLORS[theme].primary,
@ -86,7 +86,7 @@ export function WorldMap({ data, className }) {
</Geographies> </Geographies>
</ZoomableGroup> </ZoomableGroup>
</ComposableMap> </ComposableMap>
{tooltip && <HoverTooltip tooltip={tooltip} />} {tooltip && <HoverTooltip>{tooltip}</HoverTooltip>}
</div> </div>
); );
} }

View File

@ -9,9 +9,9 @@ export function LanguageButton() {
const { locale, saveLocale, dir } = useLocale(); const { locale, saveLocale, dir } = useLocale();
const items = Object.keys(languages).map(key => ({ ...languages[key], value: key })); const items = Object.keys(languages).map(key => ({ ...languages[key], value: key }));
function handleSelect(value) { function handleSelect(value, close) {
//saveLocale(value); saveLocale(value);
console.log('WTFFFF'); close();
} }
return ( return (
@ -22,24 +22,28 @@ export function LanguageButton() {
</Icon> </Icon>
</Button> </Button>
<Popup position="bottom" alignment={dir === 'rtl' ? 'start' : 'end'}> <Popup position="bottom" alignment={dir === 'rtl' ? 'start' : 'end'}>
<div className={styles.menu}> {close => {
{items.map(({ value, label }) => { return (
return ( <div className={styles.menu}>
<div {items.map(({ value, label }) => {
key={value} return (
className={classNames(styles.item, { [styles.selected]: value === locale })} <div
onClick={handleSelect.bind(null, value)} key={value}
> className={classNames(styles.item, { [styles.selected]: value === locale })}
<Text>{label}</Text> onClick={handleSelect.bind(null, value, close)}
{value === locale && ( >
<Icon className={styles.icon}> <Text>{label}</Text>
<Icons.Check /> {value === locale && (
</Icon> <Icon className={styles.icon}>
)} <Icons.Check />
</div> </Icon>
); )}
})} </div>
</div> );
})}
</div>
);
}}
</Popup> </Popup>
</PopupTrigger> </PopupTrigger>
); );

View File

@ -5,7 +5,7 @@ import Icons from 'components/icons';
import styles from './ThemeButton.module.css'; import styles from './ThemeButton.module.css';
export function ThemeButton() { export function ThemeButton() {
const [theme, setTheme] = useTheme(); const { theme, saveTheme } = useTheme();
const transitions = useTransition(theme, { const transitions = useTransition(theme, {
initial: { opacity: 1 }, initial: { opacity: 1 },
@ -21,7 +21,7 @@ export function ThemeButton() {
}); });
function handleClick() { function handleClick() {
setTheme(theme === 'light' ? 'dark' : 'light'); saveTheme(theme === 'light' ? 'dark' : 'light');
} }
return ( return (

View File

@ -123,7 +123,11 @@ export const labels = defineMessages({
reports: { id: 'label.reports', defaultMessage: 'Reports' }, reports: { id: 'label.reports', defaultMessage: 'Reports' },
eventData: { id: 'label.event-data', defaultMessage: 'Event data' }, eventData: { id: 'label.event-data', defaultMessage: 'Event data' },
funnel: { id: 'label.funnel', defaultMessage: 'Funnel' }, funnel: { id: 'label.funnel', defaultMessage: 'Funnel' },
url: { id: 'label.url', defaultMessage: 'URL' },
urls: { id: 'label.urls', defaultMessage: 'URLs' }, urls: { id: 'label.urls', defaultMessage: 'URLs' },
add: { id: 'label.add', defaultMessage: 'Add' },
window: { id: 'label.window', defaultMessage: 'Window' },
addUrl: { id: 'label.add-url', defaultMessage: 'Add URL' },
}); });
export const messages = defineMessages({ export const messages = defineMessages({

View File

@ -1,14 +1,13 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { StatusLight, Loading } from 'react-basics'; import { Loading } from 'react-basics';
import classNames from 'classnames'; import classNames from 'classnames';
import Chart from 'chart.js/auto'; import Chart from 'chart.js/auto';
import HoverTooltip from 'components/common/HoverTooltip'; import HoverTooltip from 'components/common/HoverTooltip';
import Legend from 'components/metrics/Legend'; import Legend from 'components/metrics/Legend';
import { formatLongNumber } from 'lib/format';
import { dateFormat } from 'lib/date';
import useLocale from 'hooks/useLocale'; import useLocale from 'hooks/useLocale';
import useTheme from 'hooks/useTheme'; 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'; import styles from './BarChart.module.css';
export function BarChart({ export function BarChart({
@ -17,86 +16,20 @@ export function BarChart({
animationDuration = DEFAULT_ANIMATION_DURATION, animationDuration = DEFAULT_ANIMATION_DURATION,
stacked = false, stacked = false,
loading = false, loading = false,
onCreate = () => {}, renderXLabel,
onUpdate = () => {}, renderYLabel,
XAxisType = 'time',
YAxisType = 'linear',
renderTooltip,
onCreate,
onUpdate,
className, className,
}) { }) {
const canvas = useRef(); const canvas = useRef();
const chart = useRef(null); const chart = useRef(null);
const [tooltip, setTooltip] = useState(null); const [tooltip, setTooltip] = useState(null);
const { locale } = useLocale(); const { locale } = useLocale();
const [theme] = useTheme(); const { theme, colors } = 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);
case 'year':
return dateFormat(d, 'YYY', 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(
<div className={styles.tooltip}>
<div>{dateFormat(new Date(dataPoints[0].raw.x), formats[unit], locale)}</div>
<div>
<StatusLight color={labelColors?.[0]?.backgroundColor}>
<div className={styles.value}>
{formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label}
</div>
</StatusLight>
</div>
</div>,
);
},
[unit],
);
const getOptions = useCallback(() => { const getOptions = useCallback(() => {
return { return {
@ -117,12 +50,12 @@ export function BarChart({
}, },
tooltip: { tooltip: {
enabled: false, enabled: false,
external: renderTooltip, external: renderTooltip ? renderTooltip.bind(null, setTooltip) : undefined,
}, },
}, },
scales: { scales: {
x: { x: {
type: 'time', type: XAxisType,
stacked: true, stacked: true,
time: { time: {
unit, unit,
@ -131,34 +64,44 @@ export function BarChart({
display: false, display: false,
}, },
border: { border: {
color: colors.line, color: colors.chart.line,
}, },
ticks: { ticks: {
color: colors.text, color: colors.chart.text,
autoSkip: false, autoSkip: false,
maxRotation: 0, maxRotation: 0,
callback: renderXLabel, callback: renderXLabel,
}, },
}, },
y: { y: {
type: 'linear', type: YAxisType,
min: 0, min: 0,
beginAtZero: true, beginAtZero: true,
stacked, stacked,
grid: { grid: {
color: colors.line, color: colors.chart.line,
}, },
border: { border: {
color: colors.line, color: colors.chart.line,
}, },
ticks: { ticks: {
color: colors.text, color: colors.text,
callback: renderYLabel, callback: renderYLabel || renderNumberLabels,
}, },
}, },
}, },
}; };
}, [animationDuration, renderTooltip, renderXLabel, stacked, colors, unit, locale]); }, [
animationDuration,
renderTooltip,
renderXLabel,
XAxisType,
YAxisType,
stacked,
colors,
unit,
locale,
]);
const createChart = () => { const createChart = () => {
Chart.defaults.font.family = 'Inter'; Chart.defaults.font.family = 'Inter';
@ -173,7 +116,7 @@ export function BarChart({
options, options,
}); });
onCreate(chart.current); onCreate?.(chart.current);
}; };
const updateChart = () => { const updateChart = () => {
@ -186,7 +129,7 @@ export function BarChart({
chart.current.options = getOptions(); chart.current.options = getOptions();
onUpdate(chart.current); onUpdate?.(chart.current);
chart.current.update(); chart.current.update();
}; };
@ -208,7 +151,11 @@ export function BarChart({
<canvas ref={canvas} /> <canvas ref={canvas} />
</div> </div>
<Legend chart={chart.current} /> <Legend chart={chart.current} />
{tooltip && <HoverTooltip tooltip={tooltip} />} {tooltip && (
<HoverTooltip>
<div className={styles.tooltip}>{tooltip}</div>
</HoverTooltip>
)}
</> </>
); );
} }

View File

@ -5,8 +5,8 @@ import { useSpring, animated, config } from 'react-spring';
import classNames from 'classnames'; import classNames from 'classnames';
import NoData from 'components/common/NoData'; import NoData from 'components/common/NoData';
import { formatNumber, formatLongNumber } from 'lib/format'; import { formatNumber, formatLongNumber } from 'lib/format';
import useMessages from 'hooks/useMessages';
import styles from './DataTable.module.css'; import styles from './DataTable.module.css';
import useMessages from '../../hooks/useMessages';
export function DataTable({ export function DataTable({
data = [], data = [],

View File

@ -1,9 +1,9 @@
.table { .table {
position: relative; position: relative;
height: 100%;
display: grid; display: grid;
grid-template-rows: fit-content(100%) auto; grid-template-rows: fit-content(100%) auto;
overflow: hidden; overflow: hidden;
flex: 1;
} }
.body { .body {

View File

@ -2,16 +2,15 @@ import { useMemo } from 'react';
import { Loading } from 'react-basics'; import { Loading } from 'react-basics';
import { colord } from 'colord'; import { colord } from 'colord';
import BarChart from './BarChart'; import BarChart from './BarChart';
import { getDateArray, getDateLength } from 'lib/date'; import { getDateArray } from 'lib/date';
import useApi from 'hooks/useApi'; import { useApi, useLocale, useDateRange, useTimezone, usePageQuery } from 'hooks';
import useDateRange from 'hooks/useDateRange';
import useTimezone from 'hooks/useTimezone';
import usePageQuery from 'hooks/usePageQuery';
import { EVENT_COLORS } from 'lib/constants'; import { EVENT_COLORS } from 'lib/constants';
import { renderDateLabels, renderStatusTooltip } from 'lib/charts';
export function EventsChart({ websiteId, className, token }) { export function EventsChart({ websiteId, className, token }) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const [{ startDate, endDate, unit, modified }] = useDateRange(websiteId); const [{ startDate, endDate, unit, modified }] = useDateRange(websiteId);
const { locale } = useLocale();
const [timezone] = useTimezone(); const [timezone] = useTimezone();
const { const {
query: { url, eventName }, query: { url, eventName },
@ -70,9 +69,10 @@ export function EventsChart({ websiteId, className, token }) {
datasets={datasets} datasets={datasets}
unit={unit} unit={unit}
height={300} height={300}
records={getDateLength(startDate, endDate, unit)}
loading={isLoading} loading={isLoading}
stacked stacked
renderXLabel={renderDateLabels(unit, locale)}
renderTooltip={renderStatusTooltip(unit, locale)}
/> />
); );
} }

View File

@ -1,7 +1,7 @@
.bar { .bar {
display: flex; display: flex;
cursor: pointer; cursor: pointer;
min-height: 80px; min-height: 110px;
gap: 20px; gap: 20px;
flex-wrap: wrap; flex-wrap: wrap;
} }

View File

@ -1,34 +1,13 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { colord } from 'colord';
import BarChart from './BarChart'; import BarChart from './BarChart';
import { THEME_COLORS } from 'lib/constants'; import { useLocale, useTheme, useMessages } from 'hooks';
import useTheme from 'hooks/useTheme'; import { renderDateLabels, renderStatusTooltip } from 'lib/charts';
import useMessages from 'hooks/useMessages';
import useLocale from 'hooks/useLocale';
export function PageviewsChart({ websiteId, data, unit, records, className, loading, ...props }) { export function PageviewsChart({ websiteId, data, unit, className, loading, ...props }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const [theme] = useTheme(); const { colors } = useTheme();
const { locale } = useLocale(); 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(() => { const datasets = useMemo(() => {
if (!data) return []; if (!data) return [];
@ -37,13 +16,13 @@ export function PageviewsChart({ websiteId, data, unit, records, className, load
label: formatMessage(labels.uniqueVisitors), label: formatMessage(labels.uniqueVisitors),
data: data.sessions, data: data.sessions,
borderWidth: 1, borderWidth: 1,
...colors.visitors, ...colors.chart.visitors,
}, },
{ {
label: formatMessage(labels.pageViews), label: formatMessage(labels.pageViews),
data: data.pageviews, data: data.pageviews,
borderWidth: 1, borderWidth: 1,
...colors.views, ...colors.chart.views,
}, },
]; ];
}, [data, locale, colors]); }, [data, locale, colors]);
@ -55,8 +34,9 @@ export function PageviewsChart({ websiteId, data, unit, records, className, load
className={className} className={className}
datasets={datasets} datasets={datasets}
unit={unit} unit={unit}
records={records}
loading={loading} loading={loading}
renderXLabel={renderDateLabels(unit, locale)}
renderTooltip={renderStatusTooltip(unit, locale)}
/> />
); );
} }

View File

@ -1,3 +1,9 @@
.header {
display: flex;
flex-direction: row;
align-items: center;
}
.title { .title {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -16,8 +16,6 @@ export function ReportHeader({ report, icon }) {
const { id, websiteId, name, parameters } = report || {}; const { id, websiteId, name, parameters } = report || {};
const { value, startDate, endDate } = parameters?.dateRange || {}; const { value, startDate, endDate } = parameters?.dateRange || {};
console.log('REPORT HEADER', report);
const handleSelect = websiteId => { const handleSelect = websiteId => {
updateReport(id, { websiteId }); updateReport(id, { websiteId });
}; };

View File

@ -1,184 +1,60 @@
import Chart from 'chart.js/auto'; import { useCallback, useMemo } from 'react';
import classNames from 'classnames'; import { Loading } from 'react-basics';
import { colord } from 'colord';
import HoverTooltip from 'components/common/HoverTooltip';
import Legend from 'components/metrics/Legend';
import useLocale from 'hooks/useLocale';
import useMessages from 'hooks/useMessages'; import useMessages from 'hooks/useMessages';
import useTheme from 'hooks/useTheme'; import useTheme from 'hooks/useTheme';
import { DEFAULT_ANIMATION_DURATION, THEME_COLORS } from 'lib/constants'; import BarChart from 'components/metrics/BarChart';
import { formatLongNumber } from 'lib/format'; import { formatLongNumber } from 'lib/format';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Loading, StatusLight } from 'react-basics';
import styles from './FunnelChart.module.css'; import styles from './FunnelChart.module.css';
export function FunnelChart({ export function FunnelChart({ report, data, loading, className }) {
data,
animationDuration = DEFAULT_ANIMATION_DURATION,
stacked = false,
loading = false,
onCreate = () => {},
onUpdate = () => {},
className,
}) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const canvas = useRef(); const { colors } = useTheme();
const chart = useRef(null);
const [tooltip, setTooltip] = useState(null);
const { locale } = useLocale();
const [theme] = useTheme();
const datasets = useMemo(() => { const { parameters } = report || {};
const primaryColor = colord(THEME_COLORS[theme].primary);
return [
{
label: formatMessage(labels.uniqueVisitors),
data: data,
borderWidth: 1,
hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(),
backgroundColor: primaryColor.alpha(0.6).toRgbString(),
borderColor: primaryColor.alpha(0.9).toRgbString(),
hoverBorderColor: primaryColor.toRgbString(),
},
];
}, [data]);
const colors = useMemo( const renderXLabel = useCallback(
() => ({ (label, index) => {
text: THEME_COLORS[theme].gray700, return parameters.urls[index];
line: THEME_COLORS[theme].gray200, },
}), [parameters],
[theme],
); );
const renderYLabel = label => { const renderTooltip = useCallback((setTooltip, model) => {
return +label > 1000 ? formatLongNumber(label) : label; const { opacity, dataPoints } = model.tooltip;
};
const renderTooltip = useCallback(model => {
const { opacity, labelColors, dataPoints } = model.tooltip;
if (!dataPoints?.length || !opacity) { if (!dataPoints?.length || !opacity) {
setTooltip(null); setTooltip(null);
return; return;
} }
setTooltip( setTooltip(`${formatLongNumber(dataPoints[0].raw.y)} ${formatMessage(labels.visitors)}`);
<div className={styles.tooltip}>
<div>
<StatusLight color={labelColors?.[0]?.backgroundColor}>
<div className={styles.value}>
<div>{dataPoints[0].raw.x}</div>
<div>{formatLongNumber(dataPoints[0].raw.y)}</div>
</div>
</StatusLight>
</div>
</div>,
);
}, []); }, []);
const getOptions = useCallback(() => { const datasets = useMemo(() => {
return { return [
responsive: true, {
maintainAspectRatio: false, label: formatMessage(labels.uniqueVisitors),
animation: { data: data,
duration: animationDuration, borderWidth: 1,
resize: { ...colors.chart.visitors,
duration: 0,
},
active: {
duration: 0,
},
}, },
plugins: { ];
legend: { }, [data]);
display: false,
},
tooltip: {
enabled: false,
external: renderTooltip,
},
},
scales: {
x: {
grid: {
display: false,
},
border: {
color: colors.line,
},
ticks: {
color: colors.text,
autoSkip: false,
maxRotation: 0,
},
},
y: {
type: 'linear',
min: 0,
beginAtZero: true,
stacked,
grid: {
color: colors.line,
},
border: {
color: colors.line,
},
ticks: {
color: colors.text,
callback: renderYLabel,
},
},
},
};
}, [animationDuration, renderTooltip, stacked, colors, locale]);
const createChart = () => { if (loading) {
Chart.defaults.font.family = 'Inter'; return <Loading icon="dots" className={styles.loading} />;
}
const options = getOptions();
chart.current = new Chart(canvas.current, {
type: 'bar',
data: { datasets },
options,
});
onCreate(chart.current);
};
const updateChart = () => {
setTooltip(null);
chart.current.data.datasets[0].data = datasets[0].data;
chart.current.data.datasets[0].label = datasets[0].label;
chart.current.options = getOptions();
onUpdate(chart.current);
chart.current.update();
};
useEffect(() => {
if (datasets) {
if (!chart.current) {
createChart();
} else {
updateChart();
}
}
}, [datasets, theme, animationDuration, locale]);
return ( return (
<> <BarChart
<div className={classNames(styles.chart, className)}> className={className}
{loading && <Loading position="page" icon="dots" />} datasets={datasets}
<canvas ref={canvas} /> unit="day"
</div> loading={loading}
<Legend chart={chart.current} /> renderXLabel={renderXLabel}
{tooltip && <HoverTooltip tooltip={tooltip} />} renderTooltip={renderTooltip}
</> XAxisType="category"
/>
); );
} }

View File

@ -1,23 +1,3 @@
.chart { .loading {
position: relative; height: 300px;
height: 400px;
overflow: hidden;
}
.tooltip {
display: flex;
flex-direction: column;
gap: 10px;
}
.tooltip .value {
display: flex;
flex-direction: column;
text-transform: lowercase;
}
@media only screen and (max-width: 992px) {
.chart {
/*height: 200px;*/
}
} }

View File

@ -1,36 +1,47 @@
import { useMessages } from 'hooks'; import { useMessages } from 'hooks';
import { import {
Button,
Icon, Icon,
Form, Form,
FormButtons, FormButtons,
FormInput, FormInput,
FormRow, FormRow,
PopupTrigger, ModalTrigger,
Popup, Modal,
SubmitButton, SubmitButton,
Text,
TextField, TextField,
Tooltip,
} from 'react-basics'; } from 'react-basics';
import Icons from 'components/icons'; import Icons from 'components/icons';
import { updateReport } from 'store/reports'; import { updateReport } from 'store/reports';
import { useRef } from 'react'; import { useRef, useState } from 'react';
import styles from './FunnelParameters.module.css'; import styles from './FunnelParameters.module.css';
export function FunnelParameters({ report }) { export function FunnelParameters({ report }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const ref = useRef(null); const ref = useRef(null);
const { id, websiteId, parameters, isLoading } = report || {}; const { id, websiteId, parameters, isLoading } = report || {};
const queryDisabled = !websiteId || parameters?.urls?.length < 2;
const handleSubmit = values => { const handleSubmit = values => {
console.log({ values }); updateReport(id, { parameters: values, isLoading: false, update: Date.now() });
updateReport(id, { parameters: values, isLoading: false });
}; };
console.log('PARAMETERS', parameters); const handleAdd = url => {
updateReport(id, { parameters: { ...parameters, urls: parameters.urls.concat(url) } });
};
const handleRemove = index => {
const urls = [...parameters.urls];
urls.splice(index, 1);
updateReport(id, { parameters: { ...parameters, urls } });
};
return ( return (
<> <>
<Form ref={ref} values={parameters} onSubmit={handleSubmit}> <Form ref={ref} values={parameters} onSubmit={handleSubmit}>
<FormRow label="Window (minutes)"> <FormRow label={formatMessage(labels.window)}>
<FormInput <FormInput
name="window" name="window"
rules={{ required: formatMessage(labels.required), pattern: /[0-9]+/ }} rules={{ required: formatMessage(labels.required), pattern: /[0-9]+/ }}
@ -38,11 +49,22 @@ export function FunnelParameters({ report }) {
<TextField autoComplete="off" /> <TextField autoComplete="off" />
</FormInput> </FormInput>
</FormRow> </FormRow>
<FormRow label={formatMessage(labels.urls)} action={<AddURLButton />}> <FormRow label={formatMessage(labels.urls)} action={<AddURLButton onAdd={handleAdd} />}>
hi <div className={styles.urls}>
{parameters?.urls.map((url, index) => {
return (
<div key={index} className={styles.url}>
<Text>{url}</Text>
<Icon onClick={() => handleRemove(index)}>
<Icons.Close />
</Icon>
</div>
);
})}
</div>
</FormRow> </FormRow>
<FormButtons> <FormButtons>
<SubmitButton variant="primary" disabled={!websiteId} loading={isLoading}> <SubmitButton variant="primary" disabled={queryDisabled} loading={isLoading}>
{formatMessage(labels.query)} {formatMessage(labels.query)}
</SubmitButton> </SubmitButton>
</FormButtons> </FormButtons>
@ -51,16 +73,49 @@ export function FunnelParameters({ report }) {
); );
} }
function AddURLButton() { function AddURLButton({ onAdd }) {
const [url, setUrl] = useState('');
const { formatMessage, labels } = useMessages();
const handleAdd = close => {
onAdd?.(url);
setUrl('');
close();
};
const handleChange = e => {
setUrl(e.target.value);
};
const handleClose = close => {
setUrl('');
close();
};
return ( return (
<PopupTrigger> <Tooltip label={formatMessage(labels.addUrl)}>
<Icon> <ModalTrigger>
<Icons.Plus /> <Icon>
</Icon> <Icons.Plus />
<Popup className={styles.popup} position="right" alignment="start"> </Icon>
HALLO <Modal>
</Popup> {close => {
</PopupTrigger> return (
<Form>
<FormRow label={formatMessage(labels.url)}>
<TextField name="url" value={url} onChange={handleChange} autoComplete="off" />
</FormRow>
<FormButtons align="center" flex>
<Button variant="primary" onClick={() => handleAdd(close)}>
{formatMessage(labels.add)}
</Button>
<Button onClick={() => handleClose(close)}>{formatMessage(labels.cancel)}</Button>
</FormButtons>
</Form>
);
}}
</Modal>
</ModalTrigger>
</Tooltip>
); );
} }

View File

@ -1,7 +1,16 @@
.popup { .urls {
background: var(--base50); display: flex;
padding: 20px; flex-direction: column;
margin-left: 10px; gap: 10px;
}
.url {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid var(--base400); border: 1px solid var(--base400);
border-radius: var(--border-radius); border-radius: var(--border-radius);
} box-shadow: 1px 1px 1px var(--base400);
}

View File

@ -7,21 +7,35 @@ import ReportMenu from '../ReportMenu';
import ReportBody from '../ReportBody'; import ReportBody from '../ReportBody';
import Funnel from 'assets/funnel.svg'; import Funnel from 'assets/funnel.svg';
import { useReport } from 'hooks'; import { useReport } from 'hooks';
import useApi from 'hooks/useApi';
export default function FunnelReport({ reportId }) { export default function FunnelReport({ reportId }) {
const report = useReport(reportId); const report = useReport(reportId, { window: 60, urls: ['/', '/docs'] });
const { post, useQuery } = useApi();
const { data, isLoading, error } = useQuery(
['report:funnel', report?.update],
() => {
const { websiteId, parameters } = report || {};
console.log('REPORT', { report }); return post(`/reports/funnel`, {
websiteId: websiteId,
...parameters,
startAt: +parameters.dateRange.startDate,
endAt: +parameters.dateRange.endDate,
});
},
{ enabled: !!report?.update },
);
return ( return (
<Report> <Report error={error} loading={data && isLoading}>
<ReportHeader icon={<Funnel />} report={report} /> <ReportHeader icon={<Funnel />} report={report} />
<ReportMenu> <ReportMenu>
<FunnelParameters report={report} /> <FunnelParameters report={report} />
</ReportMenu> </ReportMenu>
<ReportBody> <ReportBody>
<FunnelChart report={report} /> <FunnelChart report={report} data={data} />
<FunnelTable report={report} /> <FunnelTable data={data} />
</ReportBody> </ReportBody>
</Report> </Report>
); );

View File

@ -1,12 +1,17 @@
import DataTable from 'components/metrics/DataTable'; import DataTable from 'components/metrics/DataTable';
import { useMessages } from 'hooks';
export function FunnelTable({ ...props }) { export function FunnelTable({ data }) {
const { data } = props; const { formatMessage, labels } = useMessages();
const tableData = return (
data?.map(a => ({ x: a.x, y: a.y, z: Math.floor(a.y / data[0].y) * 100 })) || []; <DataTable
data={data}
return <DataTable data={tableData} title="Url" type="device" />; title={formatMessage(labels.url)}
metric={formatMessage(labels.visitors)}
showPercentage={false}
/>
);
} }
export default FunnelTable; export default FunnelTable;

View File

@ -6,13 +6,13 @@ import Moon from 'assets/moon.svg';
import styles from './ThemeSetting.module.css'; import styles from './ThemeSetting.module.css';
export function ThemeSetting() { export function ThemeSetting() {
const [theme, setTheme] = useTheme(); const { theme, saveTheme } = useTheme();
return ( return (
<div className={styles.buttons}> <div className={styles.buttons}>
<Button <Button
className={classNames({ [styles.active]: theme === 'light' })} className={classNames({ [styles.active]: theme === 'light' })}
onClick={() => setTheme('light')} onClick={() => saveTheme('light')}
> >
<Icon> <Icon>
<Sun /> <Sun />
@ -20,7 +20,7 @@ export function ThemeSetting() {
</Button> </Button>
<Button <Button
className={classNames({ [styles.active]: theme === 'dark' })} className={classNames({ [styles.active]: theme === 'dark' })}
onClick={() => setTheme('dark')} onClick={() => saveTheme('dark')}
> >
<Icon> <Icon>
<Moon /> <Moon />

View File

@ -1,8 +1,7 @@
import useStore, { createReport } from 'store/reports'; import useStore, { createReport } from 'store/reports';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useForceUpdate } from 'hooks';
export function useReport(reportId) { export function useReport(reportId, defaultParameters) {
const [id, setId] = useState(reportId); const [id, setId] = useState(reportId);
const selector = useCallback(state => state[id], [id]); const selector = useCallback(state => state[id], [id]);
@ -10,12 +9,11 @@ export function useReport(reportId) {
useEffect(() => { useEffect(() => {
if (!report) { if (!report) {
setId(createReport().id); const newReport = createReport(defaultParameters);
setId(newReport.id);
} }
}, []); }, []);
console.log('USE REPORT', report);
return report; return report;
} }

View File

@ -1,7 +1,8 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import useStore, { setTheme } from 'store/app'; import useStore, { setTheme } from 'store/app';
import { getItem, setItem } from 'next-basics'; import { getItem, setItem } from 'next-basics';
import { THEME_CONFIG } from 'lib/constants'; import { THEME_COLORS, THEME_CONFIG } from 'lib/constants';
import { colord } from 'colord';
const selector = state => state.theme; const selector = state => state.theme;
@ -13,6 +14,26 @@ export function useTheme() {
: 'light' : 'light'
: 'light'; : 'light';
const theme = useStore(selector) || getItem(THEME_CONFIG) || defaultTheme; const theme = useStore(selector) || getItem(THEME_CONFIG) || defaultTheme;
const primaryColor = colord(THEME_COLORS[theme].primary);
const colors = {
chart: {
text: THEME_COLORS[theme].gray700,
line: THEME_COLORS[theme].gray200,
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(),
},
},
};
function saveTheme(value) { function saveTheme(value) {
setItem(THEME_CONFIG, value); setItem(THEME_CONFIG, value);
@ -32,7 +53,7 @@ export function useTheme() {
} }
}, []); }, []);
return [theme, saveTheme]; return { theme, saveTheme, colors };
} }
export default useTheme; export default useTheme;

62
lib/charts.js Normal file
View File

@ -0,0 +1,62 @@
import { StatusLight } from 'react-basics';
import { dateFormat } from 'lib/date';
import { formatLongNumber } from 'lib/format';
export function renderNumberLabels(label) {
return +label > 1000 ? formatLongNumber(label) : label;
}
export function renderDateLabels(unit, locale) {
return (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);
case 'year':
return dateFormat(d, 'YYY', locale);
default:
return label;
}
};
}
export function renderStatusTooltip(unit, locale) {
return (setTooltip, 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(
<>
<div>{dateFormat(new Date(dataPoints[0].raw.x), formats[unit], locale)}</div>
<div>
<StatusLight color={labelColors?.[0]?.backgroundColor}>
{formatLongNumber(dataPoints[0].raw.y)} {dataPoints[0].dataset.label}
</StatusLight>
</div>
</>,
);
};
}

View File

@ -29,6 +29,7 @@ export default async (
if (req.method === 'POST') { if (req.method === 'POST') {
const { websiteId, urls, window, startAt, endAt } = req.body; const { websiteId, urls, window, startAt, endAt } = req.body;
if (!(await canViewWebsite(req.auth, websiteId))) { if (!(await canViewWebsite(req.auth, websiteId))) {
return unauthorized(res); return unauthorized(res);
} }

View File

@ -4,7 +4,7 @@ import replace from '@rollup/plugin-replace';
import { terser } from 'rollup-plugin-terser'; import { terser } from 'rollup-plugin-terser';
export default { export default {
input: 'tracker/event-data.js', input: 'tracker/index.js',
output: { output: {
file: 'public/script.js', file: 'public/script.js',
format: 'iife', format: 'iife',

View File

@ -15,8 +15,6 @@ const store = create(() => ({ ...initialState }));
export function updateReport(id, data) { export function updateReport(id, data) {
const report = store.getState()[id]; const report = store.getState()[id];
console.log('UPDATE STORE START', id, report);
if (report) { if (report) {
store.setState( store.setState(
produce(state => { produce(state => {
@ -37,9 +35,9 @@ export function updateReport(id, data) {
} }
} }
export function createReport() { export function createReport(parameters) {
const id = `new_${getRandomChars(16)}`; const id = `new_${getRandomChars(16)}`;
const report = { ...emptyReport, id }; const report = { ...emptyReport, id, parameters };
store.setState( store.setState(
produce(state => { produce(state => {
@ -49,8 +47,6 @@ export function createReport() {
}), }),
); );
console.log('CREATE STORE', report);
return report; return report;
} }