mirror of
https://github.com/plausible/analytics.git
synced 2024-12-24 01:54:34 +03:00
JS refactor: use generic ListReport for country report (#1487)
* Use ListReport for countries * Fix countries tests * Replace Browsers with ListReport * Use Listreport for OS and screen size
This commit is contained in:
parent
dae99a94b2
commit
2bdfec1cc0
@ -2,7 +2,6 @@ import React, { Fragment, useState } from 'react';
|
||||
import { Link, withRouter } from 'react-router-dom'
|
||||
import { AdjustmentsIcon, PlusIcon, XIcon, PencilIcon } from '@heroicons/react/solid'
|
||||
import classNames from 'classnames'
|
||||
import Datamap from 'datamaps'
|
||||
import { Menu, Transition } from '@headlessui/react'
|
||||
|
||||
import { appliedFilters, navigateToQuery, formattedFilters } from './query'
|
||||
@ -12,9 +11,11 @@ function removeFilter(key, history, query) {
|
||||
const newOpts = {
|
||||
[key]: false
|
||||
}
|
||||
if (key === 'goal') { newOpts.props = false }
|
||||
if (key === 'region') { newOpts.region_name = false }
|
||||
if (key === 'city') { newOpts.city_name = false }
|
||||
if (key === 'goal') { newOpts.props = false }
|
||||
if (key === 'country') { newOpts.country_name = false }
|
||||
if (key === 'region') { newOpts.region_name = false }
|
||||
if (key === 'city') { newOpts.city_name = false }
|
||||
|
||||
navigateToQuery(
|
||||
history,
|
||||
query,
|
||||
@ -59,9 +60,9 @@ function filterText(key, rawValue, query) {
|
||||
return <>{osName}.Version {type} <b>{value}</b></>
|
||||
}
|
||||
if (key === "country") {
|
||||
const allCountries = Datamap.prototype.worldTopo.objects.world.geometries;
|
||||
const selectedCountry = allCountries.find((c) => c.id === value) || {properties: {name: value}};
|
||||
return <>Country {type} <b>{selectedCountry.properties.name}</b></>
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
const countryName = q.get('country_name')
|
||||
return <>Country {type} <b>{countryName}</b></>
|
||||
}
|
||||
|
||||
if (key === "region") {
|
||||
|
@ -8,7 +8,6 @@ import GoogleKeywordsModal from './stats/modals/google-keywords'
|
||||
import PagesModal from './stats/modals/pages'
|
||||
import EntryPagesModal from './stats/modals/entry-pages'
|
||||
import ExitPagesModal from './stats/modals/exit-pages'
|
||||
import CountriesModal from './stats/modals/countries'
|
||||
import ModalTable from './stats/modals/table'
|
||||
import FilterModal from './stats/modals/filter'
|
||||
|
||||
@ -50,7 +49,7 @@ export default function Router({site, loggedIn, currentUserRole}) {
|
||||
<ExitPagesModal site={site} />
|
||||
</Route>
|
||||
<Route path="/:domain/countries">
|
||||
<CountriesModal site={site} />
|
||||
<ModalTable title="Top countries" site={site} endpoint={`/api/stats/${encodeURIComponent(site.domain)}/countries`} filter={{country: 'code', country_name: 'name'}} keyLabel="Country" />
|
||||
</Route>
|
||||
<Route path="/:domain/regions">
|
||||
<ModalTable title="Top regions" site={site} endpoint={`/api/stats/${encodeURIComponent(site.domain)}/regions`} filter={{region: 'code', region_name: 'name'}} keyLabel="Region" />
|
||||
|
@ -1,117 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import FadeIn from '../../fade-in'
|
||||
import numberFormatter from '../../number-formatter'
|
||||
import Bar from '../bar'
|
||||
import * as api from '../../api'
|
||||
import * as url from '../../url'
|
||||
import LazyLoader from '../../lazy-loader'
|
||||
|
||||
export default class Browsers extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {loading: true}
|
||||
this.onVisible = this.onVisible.bind(this)
|
||||
this.renderBrowserContent = this.renderBrowserContent.bind(this)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.query !== prevProps.query) {
|
||||
this.setState({loading: true, browsers: null})
|
||||
this.fetchBrowsers()
|
||||
}
|
||||
}
|
||||
|
||||
onVisible() {
|
||||
this.fetchBrowsers()
|
||||
if (this.props.timer) this.props.timer.onTick(this.fetchBrowsers.bind(this))
|
||||
}
|
||||
|
||||
fetchBrowsers() {
|
||||
if (this.props.query.filters.browser) {
|
||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/browser-versions`, this.props.query)
|
||||
.then((res) => this.setState({loading: false, browsers: res}))
|
||||
} else {
|
||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/browsers`, this.props.query)
|
||||
.then((res) => this.setState({loading: false, browsers: res}))
|
||||
}
|
||||
}
|
||||
|
||||
showConversionRate() {
|
||||
return !!this.props.query.filters.goal
|
||||
}
|
||||
|
||||
label() {
|
||||
return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
|
||||
}
|
||||
|
||||
renderBrowserContent(browser, link) {
|
||||
return (
|
||||
<span className="flex px-2 py-1.5 dark:text-gray-300 relative z-9 break-all">
|
||||
<Link className="md:truncate block hover:underline" to={link}>
|
||||
{browser.name}
|
||||
</Link>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
renderBrowser(browser) {
|
||||
let link;
|
||||
if (this.props.query.filters.browser) {
|
||||
link = url.setQuery('browser_version', browser.name)
|
||||
} else {
|
||||
link = url.setQuery('browser', browser.name)
|
||||
}
|
||||
const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem"
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between my-1 text-sm" key={browser.name}>
|
||||
<Bar
|
||||
count={browser.visitors}
|
||||
all={this.state.browsers}
|
||||
bg="bg-green-50 dark:bg-gray-500 dark:bg-opacity-15"
|
||||
maxWidthDeduction={maxWidthDeduction}
|
||||
>
|
||||
{this.renderBrowserContent(browser, link)}
|
||||
</Bar>
|
||||
<span className="font-medium dark:text-gray-200 text-right w-20">
|
||||
{numberFormatter(browser.visitors)} <span className="inline-block w-8 text-xs"> ({browser.percentage}%)</span>
|
||||
</span>
|
||||
{this.showConversionRate() && <span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(browser.conversion_rate)}%</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const key = this.props.query.filters.browser ? this.props.query.filters.browser + ' version' : 'Browser'
|
||||
|
||||
if (this.state.browsers && this.state.browsers.length > 0) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400">
|
||||
<span>{ key }</span>
|
||||
<div className="text-right">
|
||||
<span className="inline-block w-20">{ this.label() }</span>
|
||||
{this.showConversionRate() && <span className="inline-block w-20">CR</span>}
|
||||
</div>
|
||||
</div>
|
||||
{ this.state.browsers && this.state.browsers.map(this.renderBrowser.bind(this)) }
|
||||
</React.Fragment>
|
||||
)
|
||||
} else {
|
||||
return <div className="font-medium text-center text-gray-500 mt-44 dark:text-gray-400">No data yet</div>
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<LazyLoader onVisible={this.onVisible} className="flex flex-col flex-grow">
|
||||
{ this.state.loading && <div className="mx-auto loading mt-44"><div></div></div> }
|
||||
<FadeIn show={!this.state.loading} className="flex-grow">
|
||||
{ this.renderList() }
|
||||
</FadeIn>
|
||||
</LazyLoader>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,16 +1,94 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import * as storage from '../../storage'
|
||||
import LazyLoader from '../../lazy-loader'
|
||||
import Browsers from './browsers'
|
||||
import OperatingSystems from './operating-systems'
|
||||
import FadeIn from '../../fade-in'
|
||||
import numberFormatter from '../../number-formatter'
|
||||
import Bar from '../bar'
|
||||
import ListReport from '../reports/list'
|
||||
import * as api from '../../api'
|
||||
import * as url from '../../url'
|
||||
|
||||
function Browsers({query, site}) {
|
||||
function fetchData() {
|
||||
return api.get(url.apiPath(site, '/browsers'), query)
|
||||
}
|
||||
|
||||
return (
|
||||
<ListReport
|
||||
fetchData={fetchData}
|
||||
filter={{browser: 'name'}}
|
||||
keyLabel="Browser"
|
||||
query={query}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BrowserVersions({query, site}) {
|
||||
function fetchData() {
|
||||
return api.get(url.apiPath(site, '/browser-versions'), query)
|
||||
}
|
||||
|
||||
return (
|
||||
<ListReport
|
||||
fetchData={fetchData}
|
||||
filter={{browser_version: 'name'}}
|
||||
keyLabel={query.filters.browser + ' version'}
|
||||
query={query}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function OperatingSystems({query, site}) {
|
||||
function fetchData() {
|
||||
return api.get(url.apiPath(site, '/operating-systems'), query)
|
||||
}
|
||||
|
||||
return (
|
||||
<ListReport
|
||||
fetchData={fetchData}
|
||||
filter={{os: 'name'}}
|
||||
keyLabel="Operating system"
|
||||
query={query}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function OperatingSystemVersions({query, site}) {
|
||||
function fetchData() {
|
||||
return api.get(url.apiPath(site, '/operating-system-versions'), query)
|
||||
}
|
||||
|
||||
return (
|
||||
<ListReport
|
||||
fetchData={fetchData}
|
||||
filter={{os_version: 'name'}}
|
||||
keyLabel={query.filters.os + ' version'}
|
||||
query={query}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ScreenSizes({query, site}) {
|
||||
function fetchData() {
|
||||
return api.get(url.apiPath(site, '/screen-sizes'), query)
|
||||
}
|
||||
|
||||
function renderIcon(screenSize) {
|
||||
return iconFor(screenSize.name)
|
||||
}
|
||||
|
||||
function renderTooltipText(screenSize) {
|
||||
return EXPLANATION[screenSize.name]
|
||||
}
|
||||
|
||||
return (
|
||||
<ListReport
|
||||
fetchData={fetchData}
|
||||
filter={{screen: 'name'}}
|
||||
keyLabel="Screen size"
|
||||
query={query}
|
||||
renderIcon={renderIcon}
|
||||
tooltipText={renderTooltipText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const EXPLANATION = {
|
||||
'Mobile': 'up to 576px',
|
||||
@ -39,110 +117,6 @@ function iconFor(screenSize) {
|
||||
}
|
||||
}
|
||||
|
||||
class ScreenSizes extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {loading: true}
|
||||
this.onVisible = this.onVisible.bind(this)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.query !== prevProps.query) {
|
||||
this.setState({loading: true, sizes: null})
|
||||
this.fetchScreenSizes()
|
||||
}
|
||||
}
|
||||
|
||||
onVisible() {
|
||||
this.fetchScreenSizes()
|
||||
if (this.props.timer) this.props.timer.onTick(this.fetchScreenSizes.bind(this))
|
||||
}
|
||||
|
||||
fetchScreenSizes() {
|
||||
api.get(
|
||||
`/api/stats/${encodeURIComponent(this.props.site.domain)}/screen-sizes`,
|
||||
this.props.query
|
||||
)
|
||||
.then((res) => this.setState({loading: false, sizes: res}))
|
||||
}
|
||||
|
||||
showConversionRate() {
|
||||
return !!this.props.query.filters.goal
|
||||
}
|
||||
|
||||
|
||||
label() {
|
||||
return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
|
||||
}
|
||||
|
||||
renderScreenSize(size) {
|
||||
const query = new URLSearchParams(window.location.search)
|
||||
query.set('screen', size.name)
|
||||
const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem"
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between my-1 text-sm" key={size.name}>
|
||||
<Bar
|
||||
count={size.visitors}
|
||||
all={this.state.sizes}
|
||||
bg="bg-green-50 dark:bg-gray-500 dark:bg-opacity-15"
|
||||
maxWidthDeduction={maxWidthDeduction}
|
||||
>
|
||||
<span
|
||||
tooltip={EXPLANATION[size.name]}
|
||||
className="flex px-2 py-1.5 dark:text-gray-300"
|
||||
>
|
||||
<Link className="md:truncate block hover:underline" to={url.setQuery('screen', size.name)}>
|
||||
{iconFor(size.name)} {size.name}
|
||||
</Link>
|
||||
</span>
|
||||
</Bar>
|
||||
<span className="font-medium dark:text-gray-200 text-right w-20">
|
||||
{numberFormatter(size.visitors)} <span className="inline-block w-8 text-xs text-right">({size.percentage}%)</span>
|
||||
</span>
|
||||
{this.showConversionRate() && <span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(size.conversion_rate)}%</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderList() {
|
||||
if (this.state.sizes && this.state.sizes.length > 0) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div
|
||||
className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500"
|
||||
>
|
||||
<span>Screen size</span>
|
||||
<div className="text-right">
|
||||
<span className="inline-block w-20">{ this.label() }</span>
|
||||
{this.showConversionRate() && <span className="inline-block w-20">CR</span>}
|
||||
</div>
|
||||
</div>
|
||||
{ this.state.sizes && this.state.sizes.map(this.renderScreenSize.bind(this)) }
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="font-medium text-center text-gray-500 mt-44 dark:text-gray-400"
|
||||
>
|
||||
No data yet
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<LazyLoader onVisible={this.onVisible} className="flex flex-col flex-grow">
|
||||
{ this.state.loading && <div className="mx-auto loading mt-44"><div></div></div> }
|
||||
<FadeIn show={!this.state.loading} class="flex-grow">
|
||||
{ this.renderList() }
|
||||
</FadeIn>
|
||||
</LazyLoader>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default class Devices extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
@ -164,23 +138,19 @@ export default class Devices extends React.Component {
|
||||
renderContent() {
|
||||
switch (this.state.mode) {
|
||||
case 'browser':
|
||||
if (this.props.query.filters.browser) {
|
||||
return <BrowserVersions site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
}
|
||||
return <Browsers site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
case 'os':
|
||||
return (
|
||||
<OperatingSystems
|
||||
site={this.props.site}
|
||||
query={this.props.query}
|
||||
timer={this.props.timer}
|
||||
/>
|
||||
)
|
||||
if (this.props.query.filters.os) {
|
||||
return <OperatingSystemVersions site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
}
|
||||
return <OperatingSystems site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
case 'size':
|
||||
default:
|
||||
return (
|
||||
<ScreenSizes
|
||||
site={this.props.site}
|
||||
query={this.props.query}
|
||||
timer={this.props.timer}
|
||||
/>
|
||||
<ScreenSizes site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,111 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import FadeIn from '../../fade-in'
|
||||
import numberFormatter from '../../number-formatter'
|
||||
import Bar from '../bar'
|
||||
import * as api from '../../api'
|
||||
import * as url from '../../url'
|
||||
import LazyLoader from '../../lazy-loader'
|
||||
|
||||
export default class OperatingSystems extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {loading: true}
|
||||
this.onVisible = this.onVisible.bind(this)
|
||||
}
|
||||
|
||||
onVisible() {
|
||||
this.fetchOperatingSystems()
|
||||
if (this.props.timer) this.props.timer.onTick(this.fetchOperatingSystems.bind(this))
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.query !== prevProps.query) {
|
||||
this.setState({loading: true, operatingSystems: null})
|
||||
this.fetchOperatingSystems()
|
||||
}
|
||||
}
|
||||
|
||||
fetchOperatingSystems() {
|
||||
if (this.props.query.filters.os) {
|
||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/operating-system-versions`, this.props.query)
|
||||
.then((res) => this.setState({loading: false, operatingSystems: res}))
|
||||
} else {
|
||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/operating-systems`, this.props.query)
|
||||
.then((res) => this.setState({loading: false, operatingSystems: res}))
|
||||
}
|
||||
}
|
||||
|
||||
showConversionRate() {
|
||||
return !!this.props.query.filters.goal
|
||||
}
|
||||
|
||||
renderOperatingSystem(os) {
|
||||
let link;
|
||||
if (this.props.query.filters.os) {
|
||||
link = url.setQuery('os_version', os.name)
|
||||
} else {
|
||||
link = url.setQuery('os', os.name)
|
||||
}
|
||||
const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem"
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between my-1 text-sm"
|
||||
key={os.name}
|
||||
>
|
||||
<Bar
|
||||
count={os.visitors}
|
||||
all={this.state.operatingSystems}
|
||||
bg="bg-green-50 dark:gray-500 dark:bg-opacity-15"
|
||||
maxWidthDeduction={maxWidthDeduction}
|
||||
>
|
||||
<span className="flex px-2 py-1.5 dark:text-gray-300 relative z-9 break-all">
|
||||
<Link className="md:truncate block hover:underline" to={link}>
|
||||
{os.name}
|
||||
</Link>
|
||||
</span>
|
||||
</Bar>
|
||||
<span className="font-medium dark:text-gray-200 text-right w-20">{numberFormatter(os.visitors)} <span className="inline-block w-8 text-xs text-right">({os.percentage}%)</span></span>
|
||||
{this.showConversionRate() && <span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(os.conversion_rate)}%</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
label() {
|
||||
return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const key = this.props.query.filters.os ? this.props.query.filters.os + ' version' : 'Operating system'
|
||||
|
||||
if (this.state.operatingSystems && this.state.operatingSystems.length > 0) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400">
|
||||
<span>{ key }</span>
|
||||
<div className="text-right">
|
||||
<span className="inline-block w-20">{ this.label() }</span>
|
||||
{this.showConversionRate() && <span className="inline-block w-20">CR</span>}
|
||||
</div>
|
||||
</div>
|
||||
{ this.state.operatingSystems && this.state.operatingSystems.map(this.renderOperatingSystem.bind(this)) }
|
||||
</React.Fragment>
|
||||
)
|
||||
} else {
|
||||
return <div className="font-medium text-center text-gray-500 mt-44 dark:text-gray-400">No data yet</div>
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<LazyLoader onVisible={this.onVisible} className="flex flex-col flex-grow">
|
||||
{ this.state.loading && <div className="mx-auto loading mt-44"><div></div></div> }
|
||||
<FadeIn show={!this.state.loading} className="flex-grow">
|
||||
{ this.renderList() }
|
||||
</FadeIn>
|
||||
</LazyLoader>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,118 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom'
|
||||
import FlipMove from 'react-flip-move';
|
||||
import Datamap from 'datamaps'
|
||||
|
||||
import FadeIn from '../../fade-in'
|
||||
import Bar from '../bar'
|
||||
import MoreLink from '../more-link'
|
||||
import numberFormatter from '../../number-formatter'
|
||||
import * as api from '../../api'
|
||||
import * as url from '../../url'
|
||||
import LazyLoader from '../../lazy-loader'
|
||||
|
||||
export default class Countries extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {loading: true}
|
||||
this.onVisible = this.onVisible.bind(this)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.query !== prevProps.query) {
|
||||
this.setState({loading: true, countries: null})
|
||||
this.fetchCountries()
|
||||
}
|
||||
}
|
||||
|
||||
onVisible() {
|
||||
this.fetchCountries()
|
||||
if (this.props.timer) this.props.timer.onTick(this.fetchCountries.bind(this))
|
||||
}
|
||||
|
||||
showConversionRate() {
|
||||
return !!this.props.query.filters.goal
|
||||
}
|
||||
|
||||
fetchCountries() {
|
||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.props.query)
|
||||
.then((res) => this.setState({loading: false, countries: res}))
|
||||
}
|
||||
|
||||
label() {
|
||||
if (this.showConversionRate()) {
|
||||
return 'Conversions'
|
||||
}
|
||||
|
||||
return 'Visitors'
|
||||
}
|
||||
|
||||
renderCountry(country) {
|
||||
const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem"
|
||||
const allCountries = Datamap.prototype.worldTopo.objects.world.geometries;
|
||||
const thisCountry = allCountries.find((c) => c.id === country.name) || {properties: {name: country.name}};
|
||||
const countryFullName = thisCountry.properties.name
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between my-1 text-sm"
|
||||
key={country.name}
|
||||
>
|
||||
<Bar
|
||||
count={country.visitors}
|
||||
all={this.state.countries}
|
||||
bg="bg-orange-50 dark:bg-gray-500 dark:bg-opacity-15"
|
||||
maxWidthDeduction={maxWidthDeduction}
|
||||
>
|
||||
<span
|
||||
className="flex px-2 py-1.5 group dark:text-gray-300 relative z-9 break-all"
|
||||
>
|
||||
<Link
|
||||
to={url.setQuery('country', country.name)}
|
||||
className="md:truncate block hover:underline"
|
||||
>
|
||||
{countryFullName}
|
||||
</Link>
|
||||
</span>
|
||||
</Bar>
|
||||
<span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(country.visitors)}</span>
|
||||
{this.showConversionRate() && <span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(country.conversion_rate)}%</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderList() {
|
||||
if (this.state.countries && this.state.countries.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400">
|
||||
<span>Country</span>
|
||||
<div className="text-right">
|
||||
<span className="inline-block w-20">{ this.label() }</span>
|
||||
{this.showConversionRate() && <span className="inline-block w-20">CR</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FlipMove>
|
||||
{ this.state.countries.map(this.renderCountry.bind(this)) }
|
||||
</FlipMove>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return <div className="font-medium text-center text-gray-500 mt-44 dark:text-gray-400">No data yet</div>
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loading } = this.state;
|
||||
return (
|
||||
<LazyLoader onVisible={this.onVisible} className="flex flex-col flex-grow">
|
||||
{ loading && <div className="mx-auto loading mt-44"><div></div></div> }
|
||||
<FadeIn show={!loading} className="flex-grow">
|
||||
{ this.renderList() }
|
||||
</FadeIn>
|
||||
{!loading && <MoreLink site={this.props.site} list={this.state.countries} endpoint="countries" />}
|
||||
</LazyLoader>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,13 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
import * as storage from '../../storage'
|
||||
import Countries from './countries';
|
||||
import CountriesMap from './map'
|
||||
|
||||
import * as api from '../../api'
|
||||
import {apiPath, sitePath} from '../../url'
|
||||
import ListReport from '../reports/list'
|
||||
|
||||
function Countries({query, site}) {
|
||||
function fetchData() {
|
||||
return api.get(apiPath(site, '/countries'), query, {limit: 9}).then((res) => {
|
||||
return res.map(row => Object.assign({}, row, {percentage: undefined}))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ListReport
|
||||
fetchData={fetchData}
|
||||
filter={{country: 'code', country_name: 'name'}}
|
||||
keyLabel="Country"
|
||||
detailsLink={sitePath(site, '/countries')}
|
||||
query={query}
|
||||
color="bg-orange-50"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Regions({query, site}) {
|
||||
function fetchData() {
|
||||
return api.get(apiPath(site, '/regions'), query, {country_name: query.filters.country, limit: 9})
|
||||
@ -15,12 +33,12 @@ function Regions({query, site}) {
|
||||
|
||||
return (
|
||||
<ListReport
|
||||
title="Regions"
|
||||
fetchData={fetchData}
|
||||
filter={{region: 'code', region_name: 'name'}}
|
||||
keyLabel="Region"
|
||||
detailsLink={sitePath(site, '/regions')}
|
||||
query={query}
|
||||
color="bg-orange-50"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -32,12 +50,12 @@ function Cities({query, site}) {
|
||||
|
||||
return (
|
||||
<ListReport
|
||||
title="Cities"
|
||||
fetchData={fetchData}
|
||||
filter={{city: 'code', city_name: 'name'}}
|
||||
keyLabel="City"
|
||||
detailsLink={sitePath(site, '/cities')}
|
||||
query={query}
|
||||
color="bg-orange-50"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -1,119 +0,0 @@
|
||||
import React from "react";
|
||||
import { Link, withRouter } from 'react-router-dom'
|
||||
import Datamap from 'datamaps'
|
||||
|
||||
import Modal from './modal'
|
||||
import * as api from '../../api'
|
||||
import numberFormatter from '../../number-formatter'
|
||||
import {parseQuery} from '../../query'
|
||||
|
||||
class CountriesModal extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
loading: true,
|
||||
query: parseQuery(props.location.search, props.site)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.state.query, {limit: 300})
|
||||
.then((res) => this.setState({loading: false, countries: res}))
|
||||
}
|
||||
|
||||
label() {
|
||||
if (this.state.query.period === 'realtime') {
|
||||
return 'Current visitors'
|
||||
}
|
||||
|
||||
if (this.showConversionRate()) {
|
||||
return 'Conversions'
|
||||
}
|
||||
|
||||
return 'Visitors'
|
||||
}
|
||||
|
||||
showConversionRate() {
|
||||
return !!this.state.query.filters.goal
|
||||
}
|
||||
|
||||
renderCountry(country) {
|
||||
const query = new URLSearchParams(window.location.search)
|
||||
query.set('country', country.name)
|
||||
|
||||
const allCountries = Datamap.prototype.worldTopo.objects.world.geometries;
|
||||
const thisCountry = allCountries.find((c) => c.id === country.name) || {properties: {name: country.name}};
|
||||
const countryFullName = thisCountry.properties.name
|
||||
|
||||
return (
|
||||
<tr className="text-sm dark:text-gray-200" key={country.name}>
|
||||
<td className="p-2">
|
||||
<Link className="hover:underline" to={{search: query.toString(), pathname: `/${encodeURIComponent(this.props.site.domain)}`}}>
|
||||
{countryFullName}
|
||||
</Link>
|
||||
</td>
|
||||
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{country.total_visitors}</td> }
|
||||
<td className="p-2 w-32 font-medium" align="right">
|
||||
{numberFormatter(country.visitors)} {!this.showConversionRate() && <span className="inline-block text-xs w-8 text-right">({country.percentage}%)</span>}
|
||||
</td>
|
||||
{this.showConversionRate() && <td className="p-2 w-32 font-medium" align="right">{country.conversion_rate}%</td> }
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
renderBody() {
|
||||
if (this.state.loading) {
|
||||
return (
|
||||
<div className="loading mt-32 mx-auto"><div></div></div>
|
||||
)
|
||||
}
|
||||
|
||||
if (this.state.countries) {
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-xl font-bold dark:text-gray-100">Top countries</h1>
|
||||
|
||||
<div className="my-4 border-b border-gray-300 dark:border-gray-500"></div>
|
||||
<main className="modal__content">
|
||||
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
className="p-2 w-48 lg:w-1/2 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400"
|
||||
align="left"
|
||||
>
|
||||
Country
|
||||
</th>
|
||||
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Total visitors</th>}
|
||||
<th
|
||||
// eslint-disable-next-line max-len
|
||||
className="p-2 w-32 lg:w-1/2 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400"
|
||||
align="right"
|
||||
>
|
||||
{this.label()}
|
||||
</th>
|
||||
{this.showConversionRate() && <th className="p-2 w-32 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ this.state.countries.map(this.renderCountry.bind(this)) }
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal site={this.props.site} show={!this.state.loading}>
|
||||
{ this.renderBody() }
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(CountriesModal)
|
@ -32,7 +32,19 @@ export default class ListReport extends React.Component {
|
||||
}
|
||||
|
||||
label() {
|
||||
return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors'
|
||||
if (this.props.query.period === 'realtime') {
|
||||
return 'Current visitors'
|
||||
}
|
||||
|
||||
if (this.showConversionRate()) {
|
||||
return 'Conversions'
|
||||
}
|
||||
|
||||
return 'Visitors'
|
||||
}
|
||||
|
||||
showConversionRate() {
|
||||
return !!this.props.query.filters.goal
|
||||
}
|
||||
|
||||
renderListItem(listItem) {
|
||||
@ -42,27 +54,34 @@ export default class ListReport extends React.Component {
|
||||
query.set(key, listItem[valueKey])
|
||||
}))
|
||||
|
||||
const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem"
|
||||
const lightBackground = this.props.color || 'bg-green-50'
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between my-1 text-sm" key={listItem.name}>
|
||||
<Bar
|
||||
count={listItem.visitors}
|
||||
all={this.state.list}
|
||||
bg="bg-green-50 dark:bg-gray-500 dark:bg-opacity-15"
|
||||
maxWidthDeduction="6rem"
|
||||
bg={`${lightBackground} dark:bg-gray-500 dark:bg-opacity-15`}
|
||||
maxWidthDeduction={maxWidthDeduction}
|
||||
>
|
||||
<span className="flex px-2 py-1.5 dark:text-gray-300 relative z-9 break-all">
|
||||
<span className="flex px-2 py-1.5 dark:text-gray-300 relative z-9 break-all" tooltip={this.props.tooltipText && this.props.tooltipText(listItem)}>
|
||||
<Link className="md:truncate block hover:underline" to={{search: query.toString()}}>
|
||||
{this.props.renderIcon && this.props.renderIcon(listItem)}
|
||||
{this.props.renderIcon && ' '}
|
||||
{listItem.name}
|
||||
</Link>
|
||||
</span>
|
||||
</Bar>
|
||||
<span className="font-medium dark:text-gray-200">
|
||||
<span className="font-medium dark:text-gray-200 w-20 text-right">
|
||||
{numberFormatter(listItem.visitors)}
|
||||
{
|
||||
listItem.percentage &&
|
||||
<span className="inline-block w-8 text-xs text-right">({listItem.percentage}%)</span>
|
||||
listItem.percentage >= 0
|
||||
? <span className="inline-block w-8 text-xs text-right">({listItem.percentage}%)</span>
|
||||
: null
|
||||
}
|
||||
</span>
|
||||
{this.showConversionRate() && <span className="font-medium dark:text-gray-200 w-20 text-right">{listItem.conversion_rate}%</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -73,7 +92,10 @@ export default class ListReport extends React.Component {
|
||||
<>
|
||||
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400">
|
||||
<span>{ this.props.keyLabel }</span>
|
||||
<span>{ this.label() }</span>
|
||||
<span className="text-right">
|
||||
<span className="inline-block w-20">{this.label()}</span>
|
||||
{this.showConversionRate() && <span className="inline-block w-20">CR</span>}
|
||||
</span>
|
||||
</div>
|
||||
{ this.state.list && this.state.list.map(this.renderListItem.bind(this)) }
|
||||
</>
|
||||
|
@ -487,14 +487,14 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
countries =
|
||||
Stats.breakdown(site, query, "visit:country", ["visitors"], pagination)
|
||||
|> maybe_add_cr(site, query, {300, 1}, "country", "visit:country")
|
||||
|> transform_keys(%{"country" => "name"})
|
||||
|> transform_keys(%{"country" => "code"})
|
||||
|> maybe_add_percentages(query)
|
||||
|
||||
if params["csv"] do
|
||||
countries =
|
||||
countries
|
||||
|> Enum.map(fn country ->
|
||||
iso3166 = Stats.CountryName.from_iso3166(country["name"])
|
||||
iso3166 = Stats.CountryName.from_iso3166(country["code"])
|
||||
Map.put(country, "name", iso3166)
|
||||
end)
|
||||
|
||||
@ -508,8 +508,9 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
else
|
||||
countries =
|
||||
Enum.map(countries, fn country ->
|
||||
alpha3 = Stats.CountryName.to_alpha3(country["name"])
|
||||
Map.put(country, "name", alpha3)
|
||||
name = Stats.CountryName.from_iso3166(country["code"])
|
||||
code = Stats.CountryName.to_alpha3(country["code"])
|
||||
Map.merge(country, %{"name" => name, "code" => code})
|
||||
end)
|
||||
|
||||
json(conn, countries)
|
||||
|
@ -22,12 +22,14 @@ defmodule PlausibleWeb.Api.StatsController.CountriesTest do
|
||||
|
||||
assert json_response(conn, 200) == [
|
||||
%{
|
||||
"name" => "EST",
|
||||
"code" => "EST",
|
||||
"name" => "Estonia",
|
||||
"visitors" => 2,
|
||||
"percentage" => 67
|
||||
},
|
||||
%{
|
||||
"name" => "GBR",
|
||||
"code" => "GBR",
|
||||
"name" => "United Kingdom",
|
||||
"visitors" => 1,
|
||||
"percentage" => 33
|
||||
}
|
||||
@ -58,13 +60,15 @@ defmodule PlausibleWeb.Api.StatsController.CountriesTest do
|
||||
|
||||
assert json_response(conn, 200) == [
|
||||
%{
|
||||
"name" => "GBR",
|
||||
"code" => "GBR",
|
||||
"name" => "United Kingdom",
|
||||
"total_visitors" => 1,
|
||||
"visitors" => 1,
|
||||
"conversion_rate" => 100.0
|
||||
},
|
||||
%{
|
||||
"name" => "EST",
|
||||
"code" => "EST",
|
||||
"name" => "Estonia",
|
||||
"total_visitors" => 2,
|
||||
"visitors" => 1,
|
||||
"conversion_rate" => 50.0
|
||||
|
Loading…
Reference in New Issue
Block a user