Skip to content
Merged
36 changes: 29 additions & 7 deletions assets/js/dashboard/filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,39 @@ import React from 'react';
import { withRouter } from 'react-router-dom'
import {removeQueryParam} from './query'

function filterText(key, value) {
if (key === "goal") {
return <span className="inline-block max-w-sm truncate">Completed goal <b>{value}</b></span>
}
if (key === "source") {
return <span className="inline-block max-w-sm truncate">Source: <b>{value}</b></span>
}
if (key === "referrer") {
return <span className="inline-block max-w-sm truncate">Referrer: <b>{value}</b></span>
}
}

function renderFilter(history, [key, value]) {
function removeFilter() {
history.push({search: removeQueryParam(location.search, key)})
}

return (
<span key={key} title={value} className="inline-flex bg-white text-gray-700 shadow text-sm rounded py-2 px-3 mr-4">
{filterText(key, value)} <b className="ml-1 cursor-pointer" onClick={removeFilter}>✕</b>
</span>
)
}

function Filters({query, history, location}) {
if (query.filters.goal) {
function removeGoal() {
history.push({search: removeQueryParam(location.search, 'goal')})
}
const appliedFilters = Object.keys(query.filters)
.map((key) => [key, query.filters[key]])
.filter(([key, value]) => !!value)

if (appliedFilters.length > 0) {
return (
<div className="mt-4">
<span className="bg-white text-gray-700 shadow text-sm rounded py-2 px-3">
Completed goal <b>{query.filters.goal}</b> <b className="ml-1 cursor-pointer" onClick={removeGoal}>✕</b>
</span>
{ appliedFilters.map((filter) => renderFilter(history, filter)) }
</div>
)
}
Expand Down
6 changes: 5 additions & 1 deletion assets/js/dashboard/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ export function parseQuery(querystring, site) {
date: q.get('date') ? parseUTCDate(q.get('date')) : nowInOffset(site.offset),
from: q.get('from') ? parseUTCDate(q.get('from')) : undefined,
to: q.get('to') ? parseUTCDate(q.get('to')) : undefined,
filters: {'goal': q.get('goal')}
filters: {
'goal': q.get('goal'),
'source': q.get('source'),
'referrer': q.get('referrer')
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/stats/conversions.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default class Conversions extends React.Component {
}

renderGoalText(goalName) {
if (this.props.query.period === 'realtime') {
if (this.props.query.period === 'realtime' || this.props.query.filters['source'] || this.props.query.filters['referrer']) {
return <span className="block px-2" style={{marginTop: '-26px'}}>{goalName}</span>
} else {
const query = new URLSearchParams(window.location.search)
Expand Down
26 changes: 18 additions & 8 deletions assets/js/dashboard/stats/modals/pages.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,21 @@ class PagesModal extends React.Component {
}

componentDidMount() {
const include = this.showExtra() ? 'bounce_rate,unique_visitors' : null
const include = this.showBounceRate() ? 'bounce_rate' : null

api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/pages`, this.state.query, {limit: 100, include: include})
.then((res) => this.setState({loading: false, pages: res}))
}

showExtra() {
showBounceRate() {
return this.state.query.period !== 'realtime' && !this.state.query.filters.goal
}

showPageviews() {
const {filters} = this.state.query
return this.state.query.period !== 'realtime' && !(filters.goal || filters.source || filters.referrer)
}

formatBounceRate(page) {
if (typeof(page.bounce_rate) === 'number') {
return page.bounce_rate + '%'
Expand All @@ -39,14 +44,19 @@ class PagesModal extends React.Component {
<tr className="text-sm" key={page.name}>
<td className="p-2 truncate">{page.name}</td>
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.count)}</td>
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.unique_visitors)}</td> }
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(page)}</td> }
{this.showPageviews() && <td className="p-2 w-32 font-medium" align="right">{numberFormatter(page.pageviews)}</td> }
{this.showBounceRate() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(page)}</td> }
</tr>
)
}

label() {
return this.state.query.period === 'realtime' ? 'Active visitors' : 'Pageviews'
return this.state.query.period === 'realtime' ? 'Active visitors' : 'Visitors'
}

title() {
const {filters} = this.state.query
return (filters.source || filters.referrer) ? 'Entry Pages' : 'Top Pages'
}

renderBody() {
Expand All @@ -57,7 +67,7 @@ class PagesModal extends React.Component {
} else if (this.state.pages) {
return (
<React.Fragment>
<h1 className="text-xl font-bold">Top pages</h1>
<h1 className="text-xl font-bold">{this.title()}</h1>

<div className="my-4 border-b border-gray-300"></div>
<main className="modal__content">
Expand All @@ -66,8 +76,8 @@ class PagesModal extends React.Component {
<tr>
<th className="p-2 text-xs tracking-wide font-bold text-gray-500" align="left">Page url</th>
<th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">{ this.label() }</th>
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">Unique visitors</th>}
{this.showExtra() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">Bounce rate</th>}
{this.showPageviews() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">Pageviews</th>}
{this.showBounceRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500" align="right">Bounce rate</th>}
</tr>
</thead>
<tbody>
Expand Down
26 changes: 20 additions & 6 deletions assets/js/dashboard/stats/modals/referrer-drilldown.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,26 @@ class ReferrerDrilldownModal extends React.Component {
}
}

renderExternalLink(name) {
return (
<a target="_blank" href={'//' + name}>
<svg className="inline h-4 w-4 ml-1 -mt-1 text-gray-600" fill="currentColor" viewBox="0 0 20 20"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path></svg>
</a>
)
}

renderReferrerName(name) {
if (name) {
return <a className="hover:underline" target="_blank" href={'//' + name}>{name}</a>
} else {
return '(no referrer)'
}
const query = new URLSearchParams(window.location.search)
query.set('referrer', name)

return (
<span className="flex">
<Link className="block truncate hover:underline" to={{search: query.toString(), pathname: '/' + this.props.site.domain}} title={name}>
{ name === '' ? '(no referrer)' : name }
</Link>
{ this.renderExternalLink(name) }
</span>
)
}

renderTweet(tweet, index) {
Expand Down Expand Up @@ -130,7 +144,7 @@ class ReferrerDrilldownModal extends React.Component {
} else if (this.state.referrers) {
return (
<React.Fragment>
<Link to={`/${encodeURIComponent(this.props.site.domain)}/referrers${window.location.search}`} className="font-bold text-gray-700 hover:underline">← All referrers</Link>
<h1 className="text-xl font-bold">Referrer drilldown</h1>

<div className="my-4 border-b border-gray-300"></div>
<main className="modal__content mt-0">
Expand Down
7 changes: 5 additions & 2 deletions assets/js/dashboard/stats/modals/referrers.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,14 @@ class ReferrersModal extends React.Component {
}

renderReferrer(referrer) {
const query = new URLSearchParams(window.location.search)
query.set('source', referrer.name)

return (
<tr className="text-sm" key={referrer.name}>
<td className="p-2">
<img src={`https://icons.duckduckgo.com/ip3/${referrer.url}.ico`} className="h-4 w-4 mr-2 align-middle inline" />
<Link className="hover:underline truncate" style={{maxWidth: '80%'}} to={`/${encodeURIComponent(this.props.site.domain)}/referrers/${referrer.name}${window.location.search}`}>{ referrer.name }</Link>
<Link className="hover:underline truncate" style={{maxWidth: '80%'}} to={{search: query.toString(), pathname: '/' + encodeURIComponent(this.props.site.domain)}}>{ referrer.name }</Link>
</td>
<td className="p-2 w-32 font-medium" align="right">{numberFormatter(referrer.count)}</td>
{this.showExtra() && <td className="p-2 w-32 font-medium" align="right">{this.formatBounceRate(referrer)}</td> }
Expand All @@ -74,7 +77,7 @@ class ReferrersModal extends React.Component {
} else if (this.state.referrers) {
return (
<React.Fragment>
<h1 className="text-xl font-bold">Top Referrers</h1>
<h1 className="text-xl font-bold">Top Sources</h1>

<div className="my-4 border-b border-gray-300"></div>
<main className="modal__content">
Expand Down
19 changes: 15 additions & 4 deletions assets/js/dashboard/stats/pages.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,14 @@ export default class Pages extends React.Component {
}

fetchPages() {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/pages`, this.props.query)
.then((res) => this.setState({loading: false, pages: res}))
const {filters} = this.props.query
if (filters.source || filters.referrer) {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/entry-pages`, this.props.query)
.then((res) => this.setState({loading: false, pages: res}))
} else {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/pages`, this.props.query)
.then((res) => this.setState({loading: false, pages: res}))
}
}

renderPage(page) {
Expand All @@ -44,7 +50,7 @@ export default class Pages extends React.Component {
}

label() {
return this.props.query.period === 'realtime' ? 'Active visitors' : 'Pageviews'
return this.props.query.period === 'realtime' ? 'Active visitors' : 'Visitors'
}

renderList() {
Expand All @@ -66,11 +72,16 @@ export default class Pages extends React.Component {
}
}

title() {
const filters = this.props.query.filters
return filters['source'] || filters['referrer'] ? 'Entry Pages' : 'Top Pages'
}

renderContent() {
if (this.state.pages) {
return (
<React.Fragment>
<h3 className="font-bold">Top Pages</h3>
<h3 className="font-bold">{this.title()}</h3>
{ this.renderList() }
<MoreLink site={this.props.site} list={this.state.pages} endpoint="pages" />
</React.Fragment>
Expand Down
69 changes: 60 additions & 9 deletions assets/js/dashboard/stats/referrers.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ import MoreLink from './more-link'
import numberFormatter from '../number-formatter'
import * as api from '../api'

function LinkOption(props) {
if (props.disabled) {
return <span {...props}>{props.children}</span>
} else {
props = Object.assign({}, props, {className: props.className + ' hover:underline'})
return <Link {...props}>{props.children}</Link>
}
}

export default class Referrers extends React.Component {
constructor(props) {
super(props)
Expand All @@ -27,7 +36,11 @@ export default class Referrers extends React.Component {
}

fetchReferrers() {
if (this.props.query.filters.goal) {
if (this.props.query.filters.source) {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/referrers/${this.props.query.filters.source}`, this.props.query)
.then((res) => res.search_terms || res.referrers)
.then((referrers) => this.setState({loading: false, referrers: referrers}))
} else if (this.props.query.filters.goal) {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/goal/referrers`, this.props.query)
.then((res) => this.setState({loading: false, referrers: res}))
} else {
Expand All @@ -36,15 +49,45 @@ export default class Referrers extends React.Component {
}
}

renderExternalLink(referrer) {
if (this.props.query.filters.source && this.props.query.filters.source !== 'Google') {
return (
<a target="_blank" href={'//' + referrer.name}>
<svg className="inline h-4 w-4 ml-1 -mt-1 text-gray-600" fill="currentColor" viewBox="0 0 20 20"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path></svg>
</a>
)
}
return null
}

renderFavicon(referrer) {
if (referrer.url) {
return (
<img src={`https://icons.duckduckgo.com/ip3/${referrer.url}.ico`} className="inline h-4 w-4 mr-2 align-middle -mt-px" />
)
}
}

renderReferrer(referrer) {
const query = new URLSearchParams(window.location.search)

if (this.props.query.filters.source) {
query.set('referrer', referrer.name)
} else {
query.set('source', referrer.name)
}

return (
<div className="flex items-center justify-between my-1 text-sm" key={referrer.name}>
<div className="w-full h-8" style={{maxWidth: 'calc(100% - 4rem)'}}>
<Bar count={referrer.count} all={this.state.referrers} bg="bg-blue-50" />
<Link className="hover:underline block px-2" style={{marginTop: '-26px'}} to={`/${encodeURIComponent(this.props.site.domain)}/referrers/${referrer.name}${window.location.search}`}>
<img src={`https://icons.duckduckgo.com/ip3/${referrer.url}.ico`} className="inline h-4 w-4 mr-2 align-middle -mt-px" />
{ referrer.name }
</Link>
<span className="flex px-2" style={{marginTop: '-26px'}} >
<LinkOption className="block truncate" to={{search: query.toString()}} disabled={this.props.query.filters.goal || this.props.query.filters.source === 'Google'}>
{ this.renderFavicon(referrer) }
{ referrer.name === '' ? '(no referrer)' : referrer.name }
</LinkOption>
{ this.renderExternalLink(referrer) }
</span>
</div>
<span className="font-medium">{numberFormatter(referrer.count)}</span>
</div>
Expand All @@ -57,11 +100,15 @@ export default class Referrers extends React.Component {

renderList() {
if (this.state.referrers.length > 0) {
const source = this.props.query.filters.source
const keyLabel = source === 'Google' ? 'Search term' : source ? 'Referrer' : 'Source'
const valLabel = this.props.query.period === 'realtime' ? 'Active visitors' : 'Visitors'

return (
<React.Fragment>
<div className="flex items-center mt-3 mb-2 justify-between text-gray-500 text-xs font-bold tracking-wide">
<span>Referrer</span>
<span>{ this.label() }</span>
<span>{ keyLabel }</span>
<span>{ valLabel }</span>
</div>

<FlipMove>
Expand All @@ -75,12 +122,16 @@ export default class Referrers extends React.Component {
}

renderContent() {
const source = this.props.query.filters.source
const title = source === 'Google' ? 'Search terms' : source ? 'Top Referrers' : 'Top Sources'
const endpoint = source ? 'referrers/' + source : 'referrers'

if (this.state.referrers) {
return (
<React.Fragment>
<h3 className="font-bold">Top Referrers</h3>
<h3 className="font-bold">{title}</h3>
{ this.renderList() }
<MoreLink site={this.props.site} list={this.state.referrers} endpoint="referrers" />
<MoreLink site={this.props.site} list={this.state.referrers} endpoint={endpoint} />
</React.Fragment>
)
}
Expand Down
5 changes: 1 addition & 4 deletions assets/js/dashboard/stats/visitor-graph.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from 'react';
import { withRouter } from 'react-router-dom'
import Chart from 'chart.js'
import FadeIn from '../fade-in'
import { eventName } from '../query'
import numberFormatter, {durationFormatter} from '../number-formatter'
import * as api from '../api'
Expand Down Expand Up @@ -310,9 +309,7 @@ export default class VisitorGraph extends React.Component {
return (
<div className="w-full relative bg-white shadow-xl rounded mt-6 main-graph">
{ this.state.loading && <div className="loading pt-24 sm:pt-32 md:pt-48 mx-auto"><div></div></div> }
<FadeIn show={!this.state.loading}>
{ this.renderInner() }
</FadeIn>
{ this.renderInner() }
</div>
)
}
Expand Down
3 changes: 1 addition & 2 deletions lib/plausible/billing/billing.ex
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,7 @@ defmodule Plausible.Billing do

defp site_usage(site) do
q = Plausible.Stats.Query.from(site.timezone, %{"period" => "30d"})
{pageviews, _} = Plausible.Stats.Clickhouse.pageviews_and_visitors(site, q)
pageviews
Plausible.Stats.Clickhouse.total_events(site, q)
end

defp format_subscription(params) do
Expand Down
Loading