mirror of
https://github.com/plausible/analytics.git
synced 2024-12-27 19:47:26 +03:00
Adds list-based country report option (#1381)
* Adds list option for countries report * Changelog * Renames Geo to Locations * Lint Co-authored-by: Uku Taht <Uku.taht@gmail.com>
This commit is contained in:
parent
7e9d83d62e
commit
0c982a8670
@ -17,6 +17,7 @@ All notable changes to this project will be documented in this file.
|
||||
- Ability to invite users to sites with different roles plausible/analytics#1122
|
||||
- Option to configure a custom name for the script file
|
||||
- Add Conversion Rate to Top Sources, Top Pages Devices, Countries when filtered by a goal plausible/analytics#1299
|
||||
- Add list view for countries report in dashboard plausible/analytics#1381
|
||||
- Add ability to view more than 100 custom goal properties plausible/analytics#1353
|
||||
|
||||
### Fixed
|
||||
|
@ -7,7 +7,7 @@ import CurrentVisitors from './stats/current-visitors'
|
||||
import VisitorGraph from './stats/visitor-graph'
|
||||
import Sources from './stats/sources'
|
||||
import Pages from './stats/pages'
|
||||
import Countries from './stats/countries'
|
||||
import Locations from './stats/locations';
|
||||
import Devices from './stats/devices'
|
||||
import Conversions from './stats/conversions'
|
||||
import { withPinnedHeader } from './pinned-header-hoc';
|
||||
@ -47,7 +47,7 @@ class Historical extends React.Component {
|
||||
<Pages site={this.props.site} query={this.props.query} />
|
||||
</div>
|
||||
<div className="items-start justify-between block w-full md:flex">
|
||||
<Countries site={this.props.site} query={this.props.query} />
|
||||
<Locations site={this.props.site} query={this.props.query} />
|
||||
<Devices site={this.props.site} query={this.props.query} />
|
||||
</div>
|
||||
{ this.renderConversions() }
|
||||
|
@ -6,7 +6,7 @@ import Filters from './filters'
|
||||
import VisitorGraph from './stats/visitor-graph'
|
||||
import Sources from './stats/sources'
|
||||
import Pages from './stats/pages'
|
||||
import Countries from './stats/countries'
|
||||
import Locations from './stats/locations'
|
||||
import Devices from './stats/devices'
|
||||
import Conversions from './stats/conversions'
|
||||
import { withPinnedHeader } from './pinned-header-hoc';
|
||||
@ -45,7 +45,7 @@ class Realtime extends React.Component {
|
||||
<Pages site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
</div>
|
||||
<div className="items-start justify-between block w-full md:flex">
|
||||
<Countries site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
<Locations site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
<Devices site={this.props.site} query={this.props.query} timer={this.props.timer} />
|
||||
</div>
|
||||
|
||||
|
118
assets/js/dashboard/stats/locations/countries.js
Normal file
118
assets/js/dashboard/stats/locations/countries.js
Normal file
@ -0,0 +1,118 @@
|
||||
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.count}
|
||||
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.count)}</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>
|
||||
)
|
||||
}
|
||||
}
|
88
assets/js/dashboard/stats/locations/index.js
Normal file
88
assets/js/dashboard/stats/locations/index.js
Normal file
@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
|
||||
import * as storage from '../../storage'
|
||||
import Countries from './countries';
|
||||
import CountriesMap from './map'
|
||||
|
||||
const labelFor = {
|
||||
'map': 'Countries Map',
|
||||
'countries': 'Countries',
|
||||
'regions': 'Regions',
|
||||
'cities': 'Cities',
|
||||
}
|
||||
|
||||
export default class Locations extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.tabKey = `geoTab__${ props.site.domain}`
|
||||
const storedTab = storage.getItem(this.tabKey)
|
||||
this.state = {
|
||||
mode: storedTab || 'map'
|
||||
}
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
return () => {
|
||||
storage.setItem(this.tabKey, mode)
|
||||
this.setState({mode})
|
||||
}
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
switch(this.state.mode) {
|
||||
case "countries":
|
||||
return <Countries site={this.props.site} query={this.props.query} timer={this.props.timer}/>
|
||||
case "map":
|
||||
default:
|
||||
return <CountriesMap site={this.props.site} query={this.props.query} timer={this.props.timer}/>
|
||||
}
|
||||
}
|
||||
|
||||
renderPill(name, mode) {
|
||||
const isActive = this.state.mode === mode
|
||||
|
||||
if (isActive) {
|
||||
return (
|
||||
<li
|
||||
className="inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold border-b-2 border-indigo-700 dark:border-indigo-500"
|
||||
>
|
||||
{name}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className="hover:text-indigo-600 cursor-pointer"
|
||||
onClick={this.setMode(mode)}
|
||||
>
|
||||
{name}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
className="stats-item flex flex-col w-full mt-6 stats-item--has-header"
|
||||
>
|
||||
<div
|
||||
className="stats-item__header flex flex-col flex-grow bg-white dark:bg-gray-825 shadow-xl rounded p-4 relative"
|
||||
>
|
||||
<div className="w-full flex justify-between">
|
||||
<h3 className="font-bold dark:text-gray-100">
|
||||
{labelFor[this.state.mode] || 'Locations'}
|
||||
</h3>
|
||||
<ul className="flex font-medium text-xs text-gray-500 dark:text-gray-400 space-x-2">
|
||||
{ this.renderPill('Map', 'map') }
|
||||
{ this.renderPill('Countries', 'countries') }
|
||||
{/* { this.renderPill('Regions', 'regions') } */}
|
||||
{/* { this.renderPill('Cities', 'cities') } */}
|
||||
</ul>
|
||||
</div>
|
||||
{ this.renderContent() }
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -3,15 +3,15 @@ import Datamap from 'datamaps'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import * as d3 from "d3"
|
||||
|
||||
import numberFormatter from '../number-formatter'
|
||||
import FadeIn from '../fade-in'
|
||||
import LazyLoader from '../lazy-loader'
|
||||
import MoreLink from './more-link'
|
||||
import * as api from '../api'
|
||||
import { navigateToQuery } from '../query'
|
||||
import { withThemeConsumer } from '../theme-consumer-hoc';
|
||||
import numberFormatter from '../../number-formatter'
|
||||
import FadeIn from '../../fade-in'
|
||||
import LazyLoader from '../../lazy-loader'
|
||||
import MoreLink from '../more-link'
|
||||
import * as api from '../../api'
|
||||
import { navigateToQuery } from '../../query'
|
||||
import { withThemeConsumer } from '../../theme-consumer-hoc';
|
||||
|
||||
class Countries extends React.Component {
|
||||
class CountriesMap extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.resizeMap = this.resizeMap.bind(this)
|
||||
@ -137,8 +137,7 @@ class Countries extends React.Component {
|
||||
if (this.state.countries) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<h3 className="font-bold dark:text-gray-100">Countries</h3>
|
||||
<div className="mx-auto mt-6" style={{width: '100%', maxWidth: '475px', height: '335px'}} id="map-container"></div>
|
||||
<div className="mx-auto mt-4" style={{width: '100%', maxWidth: '475px', height: '335px'}} id="map-container"></div>
|
||||
<MoreLink site={this.props.site} list={this.state.countries} endpoint="countries" />
|
||||
{ this.geolocationDbNotice() }
|
||||
</React.Fragment>
|
||||
@ -148,18 +147,14 @@ class Countries extends React.Component {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div
|
||||
className="relative p-4 bg-white rounded shadow-xl stats-item flex flex-col dark:bg-gray-825 mt-6 w-full"
|
||||
>
|
||||
<LazyLoader onVisible={this.onVisible}>
|
||||
{ this.state.loading && <div className="mx-auto my-32 loading"><div></div></div> }
|
||||
<FadeIn show={!this.state.loading}>
|
||||
{ this.renderBody() }
|
||||
</FadeIn>
|
||||
</LazyLoader>
|
||||
</div>
|
||||
<LazyLoader onVisible={this.onVisible}>
|
||||
{ this.state.loading && <div className="mx-auto my-32 loading"><div></div></div> }
|
||||
<FadeIn show={!this.state.loading}>
|
||||
{ this.renderBody() }
|
||||
</FadeIn>
|
||||
</LazyLoader>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(withThemeConsumer(Countries))
|
||||
export default withRouter(withThemeConsumer(CountriesMap))
|
@ -17,7 +17,7 @@ class CountriesModal extends React.Component {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.state.query, {limit: 100})
|
||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.state.query, {limit: 300})
|
||||
.then((res) => this.setState({loading: false, countries: res}))
|
||||
}
|
||||
|
||||
|
@ -404,9 +404,10 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
def countries(conn, params) do
|
||||
site = conn.assigns[:site]
|
||||
query = Query.from(site.timezone, params) |> Filters.add_prefix()
|
||||
pagination = parse_pagination(params)
|
||||
|
||||
countries =
|
||||
Stats.breakdown(site, query, "visit:country", ["visitors"], {300, 1})
|
||||
Stats.breakdown(site, query, "visit:country", ["visitors"], pagination)
|
||||
|> maybe_add_cr(site, query, {300, 1}, "country", "visit:country")
|
||||
|> transform_keys(%{"country" => "name", "visitors" => "count"})
|
||||
|> Enum.map(fn country ->
|
||||
|
Loading…
Reference in New Issue
Block a user