Website edit functionality.

This commit is contained in:
Mike Cao 2020-08-07 02:27:12 -07:00
parent 30b87bc4c4
commit 00e232fee8
63 changed files with 301 additions and 94 deletions

View file

@ -0,0 +1,20 @@
import React from 'react';
import { useSpring, animated } from 'react-spring';
import styles from './MetricCard.module.css';
function defaultFormat(n) {
return Number(n).toFixed(0);
}
const MetricCard = ({ value = 0, label, format = defaultFormat }) => {
const props = useSpring({ x: value, from: { x: 0 } });
return (
<div className={styles.card}>
<animated.div className={styles.value}>{props.x.interpolate(x => format(x))}</animated.div>
<div className={styles.label}>{label}</div>
</div>
);
};
export default MetricCard;

View file

@ -0,0 +1,17 @@
.card {
display: flex;
flex-direction: column;
justify-content: center;
width: 140px;
}
.value {
font-size: var(--font-size-xlarge);
line-height: 40px;
min-height: 40px;
font-weight: 600;
}
.label {
font-size: var(--font-size-normal);
}

View file

@ -0,0 +1,41 @@
import React, { useState, useEffect } from 'react';
import classNames from 'classnames';
import MetricCard from './MetricCard';
import { get } from 'lib/web';
import { formatShortTime } from 'lib/format';
import styles from './MetricsBar.module.css';
export default function MetricsBar({ websiteId, startDate, endDate, className }) {
const [data, setData] = useState({});
const { pageviews, uniques, bounces, totaltime } = data;
async function loadData() {
setData(
await get(`/api/website/${websiteId}/metrics`, {
start_at: +startDate,
end_at: +endDate,
}),
);
}
useEffect(() => {
loadData();
}, [websiteId, startDate, endDate]);
return (
<div className={classNames(styles.container, className)}>
<MetricCard label="Views" value={pageviews} />
<MetricCard label="Visitors" value={uniques} />
<MetricCard
label="Bounce rate"
value={uniques ? (bounces / uniques) * 100 : 0}
format={n => Number(n).toFixed(0) + '%'}
/>
<MetricCard
label="Average visit time"
value={totaltime && pageviews ? totaltime / (pageviews - bounces) : 0}
format={n => formatShortTime(n, ['m', 's'], ' ')}
/>
</div>
);
}

View file

@ -0,0 +1,9 @@
.container {
display: flex;
}
@media only screen and (max-width: 1000px) {
.container > div:last-child {
display: none;
}
}

View file

@ -0,0 +1,175 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import ReactTooltip from 'react-tooltip';
import classNames from 'classnames';
import ChartJS from 'chart.js';
import { format } from 'date-fns';
import styles from './PageviewsChart.module.css';
export default function PageviewsChart({
websiteId,
data,
unit,
animationDuration = 300,
className,
children,
}) {
const canvas = useRef();
const chart = useRef();
const [tooltip, setTooltip] = useState({});
const renderLabel = useCallback(
(label, index, values) => {
const d = new Date(values[index].value);
const n = data.pageviews.length;
switch (unit) {
case 'day':
if (n >= 15) {
return index % ~~(n / 15) === 0 ? format(d, 'MMM d') : '';
}
return format(d, 'EEE M/d');
case 'month':
return format(d, 'MMMM');
default:
return label;
}
},
[unit, data],
);
const renderTooltip = model => {
const { opacity, title, body, labelColors } = model;
if (!opacity) {
setTooltip(null);
} else {
const [label, value] = body[0].lines[0].split(':');
setTooltip({
title: title[0],
value,
label,
labelColor: labelColors[0].backgroundColor,
});
}
};
function draw() {
if (!canvas.current) return;
if (!chart.current) {
chart.current = new ChartJS(canvas.current, {
type: 'bar',
data: {
datasets: [
{
label: 'unique visitors',
data: data.uniques,
lineTension: 0,
backgroundColor: 'rgb(38, 128, 235, 0.4)',
borderColor: 'rgb(13, 102, 208, 0.4)',
borderWidth: 1,
},
{
label: 'page views',
data: data.pageviews,
lineTension: 0,
backgroundColor: 'rgb(38, 128, 235, 0.2)',
borderColor: 'rgb(13, 102, 208, 0.2)',
borderWidth: 1,
},
],
},
options: {
animation: {
duration: animationDuration,
},
tooltips: {
enabled: false,
custom: renderTooltip,
},
hover: {
animationDuration: 0,
},
scales: {
xAxes: [
{
type: 'time',
distribution: 'series',
time: {
unit,
tooltipFormat: 'ddd MMMM DD YYYY',
},
ticks: {
callback: renderLabel,
maxRotation: 0,
},
gridLines: {
display: false,
},
offset: true,
stacked: true,
},
],
yAxes: [
{
ticks: {
beginAtZero: true,
},
stacked: true,
},
],
},
},
});
} else {
const {
data: { datasets },
options,
} = chart.current;
datasets[0].data = data.uniques;
datasets[1].data = data.pageviews;
options.scales.xAxes[0].time.unit = unit;
options.scales.xAxes[0].ticks.callback = renderLabel;
options.animation.duration = animationDuration;
chart.current.update();
}
}
useEffect(() => {
if (data) {
draw();
setTooltip(null);
}
}, [data]);
return (
<div
data-tip=""
data-for={`${websiteId}-tooltip`}
className={classNames(styles.chart, className)}
>
<canvas ref={canvas} width={960} height={400} />
<ReactTooltip id={`${websiteId}-tooltip`}>
{tooltip ? <Tooltip {...tooltip} /> : null}
</ReactTooltip>
{children}
</div>
);
}
const Tooltip = ({ title, value, label, labelColor }) => (
<div className={styles.tooltip}>
<div className={styles.content}>
<div className={styles.title}>{title}</div>
<div className={styles.metric}>
<div className={styles.dot}>
<div className={styles.color} style={{ backgroundColor: labelColor }} />
</div>
{value} {label}
</div>
</div>
</div>
);

View file

@ -0,0 +1,43 @@
.chart {
position: relative;
}
.tooltip {
pointer-events: none;
z-index: 1;
}
.content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--gray50);
text-align: center;
}
.title {
font-size: var(--font-size-xsmall);
font-weight: 600;
}
.metric {
display: flex;
justify-content: center;
align-items: center;
font-size: var(--font-size-small);
font-weight: 600;
}
.dot {
position: relative;
overflow: hidden;
border-radius: 100%;
margin-right: 8px;
background: var(--gray50);
}
.color {
width: 10px;
height: 10px;
}

View file

@ -0,0 +1,31 @@
import React from 'react';
import classNames from 'classnames';
import Button from '../interface/Button';
import { getDateRange } from 'lib/date';
import styles from './QuickButtons.module.css';
const options = {
'24hour': '24h',
'7day': '7d',
'30day': '30d',
};
export default function QuickButtons({ value, onChange }) {
function handleClick(value) {
onChange(getDateRange(value));
}
return (
<div className={styles.buttons}>
{Object.keys(options).map(key => (
<Button
key={key}
className={classNames(styles.button, { [styles.active]: value === key })}
onClick={() => handleClick(key)}
>
{options[key]}
</Button>
))}
</div>
);
}

View file

@ -0,0 +1,26 @@
.buttons {
display: flex;
position: absolute;
top: 0;
right: 0;
margin: auto;
}
.buttons button + button {
margin-left: 10px;
}
.button {
font-size: var(--font-size-xsmall);
padding: 4px 8px;
}
.active {
font-weight: 600;
}
@media only screen and (max-width: 720px) {
.buttons button:last-child {
display: none;
}
}

View file

@ -0,0 +1,94 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useSpring, animated, config } from 'react-spring';
import classNames from 'classnames';
import CheckVisible from '../helpers/CheckVisible';
import { get } from 'lib/web';
import { percentFilter } from 'lib/filters';
import styles from './RankingsChart.module.css';
export default function RankingsChart({
title,
websiteId,
startDate,
endDate,
type,
heading,
className,
dataFilter,
onDataLoad = () => {},
}) {
const [data, setData] = useState();
const rankings = useMemo(() => {
if (data) {
return (dataFilter ? dataFilter(data) : data).filter((e, i) => i < 10);
}
return [];
}, [data]);
async function loadData() {
const data = await get(`/api/website/${websiteId}/rankings`, {
start_at: +startDate,
end_at: +endDate,
type,
});
const updated = percentFilter(data);
setData(updated);
onDataLoad(updated);
}
useEffect(() => {
if (websiteId) {
loadData();
}
}, [websiteId, startDate, endDate, type]);
if (!data) {
return null;
}
return (
<CheckVisible>
{visible => (
<div className={classNames(styles.container, className)}>
<div className={styles.header}>
<div className={styles.title}>{title}</div>
<div className={styles.heading}>{heading}</div>
</div>
<div className={styles.body}>
{rankings.map(({ x, y, z }) => (
<Row key={x} label={x} value={y} percent={z} animate={visible} />
))}
</div>
</div>
)}
</CheckVisible>
);
}
const Row = ({ label, value, percent, animate }) => {
const props = useSpring({
width: percent,
y: value,
from: { width: 0, y: 0 },
config: animate ? config.default : { duration: 0 },
});
return (
<div className={styles.row}>
<div className={styles.label}>{label}</div>
<animated.div className={styles.value}>{props.y.interpolate(n => n.toFixed(0))}</animated.div>
<div className={styles.percent}>
<animated.div
className={styles.bar}
style={{ width: props.width.interpolate(n => `${n}%`) }}
/>
<animated.span className={styles.percentValue}>
{props.width.interpolate(n => `${n.toFixed(0)}%`)}
</animated.span>
</div>
</div>
);
};

View file

@ -0,0 +1,97 @@
.container {
position: relative;
min-height: 430px;
font-size: var(--font-size-small);
padding: 20px 0;
display: flex;
flex-direction: column;
}
.header {
display: flex;
line-height: 40px;
}
.title {
flex: 1;
font-weight: 600;
font-size: var(--font-size-normal);
}
.heading {
font-size: var(--font-size-small);
text-align: center;
width: 100px;
}
.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:empty {
color: #b3b3b3;
}
.label:empty:before {
content: 'Unknown';
}
.value {
width: 50px;
text-align: right;
margin-right: 10px;
font-weight: 600;
}
.percent {
position: relative;
width: 50px;
color: #6e6e6e;
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;
}
.body {
position: relative;
flex: 1;
}
.body:empty:before {
content: 'No data available';
color: var(--gray500);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@media only screen and (max-width: 992px) {
.container {
min-height: auto;
}
}

View file

@ -0,0 +1,90 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import classNames from 'classnames';
import PageviewsChart from './PageviewsChart';
import CheckVisible from '../helpers/CheckVisible';
import MetricsBar from './MetricsBar';
import QuickButtons from './QuickButtons';
import DateFilter from '../common/DateFilter';
import StickyHeader from '../helpers/StickyHeader';
import { get } from 'lib/web';
import { getDateArray, getDateRange, getTimezone } from 'lib/date';
import styles from './WebsiteChart.module.css';
export default function WebsiteChart({
websiteId,
defaultDateRange = '7day',
stickyHeader = false,
onDataLoad = () => {},
onDateChange = () => {},
}) {
const [data, setData] = useState();
const [dateRange, setDateRange] = useState(getDateRange(defaultDateRange));
const { startDate, endDate, unit, value } = dateRange;
const [pageviews, uniques] = useMemo(() => {
if (data) {
return [
getDateArray(data.pageviews, startDate, endDate, unit),
getDateArray(data.uniques, startDate, endDate, unit),
];
}
return [[], []];
}, [data]);
function handleDateChange(values) {
setDateRange(values);
onDateChange(values);
}
async function loadData() {
const data = await get(`/api/website/${websiteId}/pageviews`, {
start_at: +startDate,
end_at: +endDate,
unit,
tz: getTimezone(),
});
setData(data);
onDataLoad(data);
}
useEffect(() => {
loadData();
}, [websiteId, startDate, endDate, unit]);
return (
<>
<StickyHeader
className={classNames(styles.header, 'row')}
stickyClassName={styles.sticky}
enabled={stickyHeader}
>
<MetricsBar
className="col-12 col-md-9 col-lg-10"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
/>
<DateFilter
className="col-12 col-md-3 col-lg-2"
value={value}
onChange={handleDateChange}
/>
</StickyHeader>
<div className="row">
<CheckVisible className="col">
{visible => (
<PageviewsChart
websiteId={websiteId}
data={{ pageviews, uniques }}
unit={unit}
animationDuration={visible ? 300 : 0}
>
<QuickButtons value={value} onChange={handleDateChange} />
</PageviewsChart>
)}
</CheckVisible>
</div>
</>
);
}

View file

@ -0,0 +1,26 @@
.container {
display: flex;
flex-direction: column;
}
.title {
font-size: var(--font-size-large);
line-height: 60px;
font-weight: 600;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
}
.sticky {
position: fixed;
top: 0;
margin: auto;
background: var(--gray50);
border-bottom: 1px solid var(--gray300);
z-index: 2;
}