mirror of
https://github.com/plausible/analytics.git
synced 2024-11-23 03:04:43 +03:00
Conversions listreport (#3215)
* rename conversions.js to deprecated-conversions.js * add a new Conversions component and switch by props FF * add a Details view to the new Conversions component * allow querying conversions with pagination ...and keep the current behaviour for DeprecatedConversions, always returning page=1 and limit=100 * hide some columns on mobile * prevent ListReport first column header overflow
This commit is contained in:
parent
8ac166b447
commit
232bdd34a1
@ -10,6 +10,7 @@ import EntryPagesModal from './stats/modals/entry-pages'
|
||||
import ExitPagesModal from './stats/modals/exit-pages'
|
||||
import ModalTable from './stats/modals/table'
|
||||
import PropsModal from './stats/modals/props'
|
||||
import ConversionsModal from './stats/modals/conversions'
|
||||
import FilterModal from './stats/modals/filter-modal'
|
||||
import * as url from './util/url';
|
||||
|
||||
@ -62,6 +63,9 @@ export default function Router({site, loggedIn, currentUserRole}) {
|
||||
<Route path="/:domain/custom-prop-values/:prop_key">
|
||||
<PropsModal site={site}/>
|
||||
</Route>
|
||||
<Route path="/:domain/conversions">
|
||||
<ConversionsModal site={site}/>
|
||||
</Route>
|
||||
<Route path={["/:domain/filter/:field"]}>
|
||||
<FilterModal site={site} />
|
||||
</Route>
|
||||
|
@ -1,119 +1,37 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom'
|
||||
import FlipMove from 'react-flip-move'
|
||||
|
||||
import Bar from '../bar'
|
||||
import PropBreakdown from './prop-breakdown'
|
||||
import numberFormatter from '../../util/number-formatter'
|
||||
import * as api from '../../api'
|
||||
import * as url from '../../util/url'
|
||||
import { escapeFilterValue } from '../../util/filters'
|
||||
import LazyLoader from '../../components/lazy-loader'
|
||||
import Money from './money'
|
||||
|
||||
export default class Conversions extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.htmlNode = React.createRef()
|
||||
this.state = { loading: true, }
|
||||
this.onVisible = this.onVisible.bind(this)
|
||||
this.fetchConversions = this.fetchConversions.bind(this)
|
||||
import { CR_METRIC } from '../reports/metrics';
|
||||
import ListReport from '../reports/list';
|
||||
|
||||
export default function Conversions(props) {
|
||||
const {site, query} = props
|
||||
|
||||
function fetchConversions() {
|
||||
return api.get(url.apiPath(site, '/conversions'), query, {limit: 20})
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('tick', this.fetchConversions)
|
||||
function getFilterFor(listItem) {
|
||||
return {goal: escapeFilterValue(listItem.name)}
|
||||
}
|
||||
|
||||
onVisible() {
|
||||
this.fetchConversions()
|
||||
if (this.props.query.period === 'realtime') {
|
||||
document.addEventListener('tick', this.fetchConversions)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.query !== prevProps.query) {
|
||||
const height = this.htmlNode.current.offsetHeight
|
||||
this.setState({ loading: true, goals: null, prevHeight: height })
|
||||
this.fetchConversions()
|
||||
}
|
||||
}
|
||||
|
||||
fetchConversions() {
|
||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/conversions`, this.props.query)
|
||||
.then((res) => this.setState({ loading: false, goals: res, prevHeight: null }))
|
||||
}
|
||||
|
||||
renderGoal(goal, renderRevenueColumn) {
|
||||
const renderProps = this.props.query.filters['goal'] == goal.name && goal.prop_names
|
||||
|
||||
return (
|
||||
<div className="my-2 text-sm" key={goal.name}>
|
||||
<div className="flex items-center justify-between my-2">
|
||||
<span className="flex-1">
|
||||
<Bar
|
||||
count={goal.visitors}
|
||||
all={this.state.goals}
|
||||
bg="bg-red-50 dark:bg-gray-500 dark:bg-opacity-15"
|
||||
plot="visitors"
|
||||
>
|
||||
<Link to={url.setQuery('goal', escapeFilterValue(goal.name))} className="block px-2 py-1.5 hover:underline relative z-9 break-all lg:truncate dark:text-gray-200">{goal.name}</Link>
|
||||
</Bar>
|
||||
</span>
|
||||
<div className="dark:text-gray-200">
|
||||
<span className="inline-block w-20 font-medium text-right">{numberFormatter(goal.visitors)}</span>
|
||||
<span className="hidden md:inline-block md:w-20 font-medium text-right">{numberFormatter(goal.events)}</span>
|
||||
<span className="inline-block w-20 font-medium text-right">{goal.conversion_rate}%</span>
|
||||
{renderRevenueColumn && <span className="hidden md:inline-block md:w-20 font-medium text-right"><Money formatted={goal.total_revenue} /></span>}
|
||||
{renderRevenueColumn && <span className="hidden md:inline-block md:w-20 font-medium text-right"><Money formatted={goal.average_revenue} /></span>}
|
||||
</div>
|
||||
</div>
|
||||
{ renderProps && <PropBreakdown site={this.props.site} query={this.props.query} goal={goal} renderRevenueColumn={renderRevenueColumn } /> }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderInner() {
|
||||
if (this.state.loading) {
|
||||
return <div className="mx-auto my-2 loading"><div></div></div>
|
||||
} else if (this.state.goals) {
|
||||
const hasRevenue = this.state.goals.some((goal) => goal.total_revenue)
|
||||
|
||||
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>Goal</span>
|
||||
<div className="text-right">
|
||||
<span className="inline-block w-20">Uniques</span>
|
||||
<span className="hidden md:inline-block md:w-20">Total</span>
|
||||
<span className="inline-block w-20">CR</span>
|
||||
{hasRevenue && <span className="hidden md:inline-block md:w-20">Revenue</span>}
|
||||
{hasRevenue && <span className="hidden md:inline-block md:w-20">Average</span>}
|
||||
</div>
|
||||
</div>
|
||||
<FlipMove>
|
||||
{ this.state.goals.map((goal) => this.renderGoal.bind(this)(goal, hasRevenue) ) }
|
||||
</FlipMove>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
renderConversions() {
|
||||
return (
|
||||
<div ref={this.htmlNode} style={{ minHeight: '132px', height: this.state.prevHeight ?? 'auto' }} >
|
||||
<LazyLoader onVisible={this.onVisible}>
|
||||
{this.renderInner()}
|
||||
</LazyLoader>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderConversions()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ListReport
|
||||
fetchData={fetchConversions}
|
||||
getFilterFor={getFilterFor}
|
||||
keyLabel="Goal"
|
||||
metrics={[
|
||||
{name: 'visitors', label: "Uniques", plot: true},
|
||||
{name: 'events', label: "Total", hiddenOnMobile: true},
|
||||
CR_METRIC,
|
||||
{name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true},
|
||||
{name: 'average_revenue', label: 'Average', hiddenOnMobile: true}
|
||||
]}
|
||||
detailsLink={url.sitePath(site, '/conversions')}
|
||||
query={query}
|
||||
color="bg-red-50"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
119
assets/js/dashboard/stats/behaviours/deprecated-conversions.js
Normal file
119
assets/js/dashboard/stats/behaviours/deprecated-conversions.js
Normal file
@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom'
|
||||
import FlipMove from 'react-flip-move'
|
||||
|
||||
import Bar from '../bar'
|
||||
import PropBreakdown from './prop-breakdown'
|
||||
import numberFormatter from '../../util/number-formatter'
|
||||
import * as api from '../../api'
|
||||
import * as url from '../../util/url'
|
||||
import { escapeFilterValue } from '../../util/filters'
|
||||
import LazyLoader from '../../components/lazy-loader'
|
||||
import Money from './money'
|
||||
|
||||
export default class DeprecatedConversions extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.htmlNode = React.createRef()
|
||||
this.state = { loading: true, }
|
||||
this.onVisible = this.onVisible.bind(this)
|
||||
this.fetchConversions = this.fetchConversions.bind(this)
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('tick', this.fetchConversions)
|
||||
}
|
||||
|
||||
onVisible() {
|
||||
this.fetchConversions()
|
||||
if (this.props.query.period === 'realtime') {
|
||||
document.addEventListener('tick', this.fetchConversions)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.query !== prevProps.query) {
|
||||
const height = this.htmlNode.current.offsetHeight
|
||||
this.setState({ loading: true, goals: null, prevHeight: height })
|
||||
this.fetchConversions()
|
||||
}
|
||||
}
|
||||
|
||||
fetchConversions() {
|
||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/conversions`, this.props.query, {limit: 100})
|
||||
.then((res) => this.setState({ loading: false, goals: res, prevHeight: null }))
|
||||
}
|
||||
|
||||
renderGoal(goal, renderRevenueColumn) {
|
||||
const renderProps = this.props.query.filters['goal'] == goal.name && goal.prop_names
|
||||
|
||||
return (
|
||||
<div className="my-2 text-sm" key={goal.name}>
|
||||
<div className="flex items-center justify-between my-2">
|
||||
<span className="flex-1">
|
||||
<Bar
|
||||
count={goal.visitors}
|
||||
all={this.state.goals}
|
||||
bg="bg-red-50 dark:bg-gray-500 dark:bg-opacity-15"
|
||||
plot="visitors"
|
||||
>
|
||||
<Link to={url.setQuery('goal', escapeFilterValue(goal.name))} className="block px-2 py-1.5 hover:underline relative z-9 break-all lg:truncate dark:text-gray-200">{goal.name}</Link>
|
||||
</Bar>
|
||||
</span>
|
||||
<div className="dark:text-gray-200">
|
||||
<span className="inline-block w-20 font-medium text-right">{numberFormatter(goal.visitors)}</span>
|
||||
<span className="hidden md:inline-block md:w-20 font-medium text-right">{numberFormatter(goal.events)}</span>
|
||||
<span className="inline-block w-20 font-medium text-right">{goal.conversion_rate}%</span>
|
||||
{renderRevenueColumn && <span className="hidden md:inline-block md:w-20 font-medium text-right"><Money formatted={goal.total_revenue} /></span>}
|
||||
{renderRevenueColumn && <span className="hidden md:inline-block md:w-20 font-medium text-right"><Money formatted={goal.average_revenue} /></span>}
|
||||
</div>
|
||||
</div>
|
||||
{ renderProps && <PropBreakdown site={this.props.site} query={this.props.query} goal={goal} renderRevenueColumn={renderRevenueColumn } /> }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderInner() {
|
||||
if (this.state.loading) {
|
||||
return <div className="mx-auto my-2 loading"><div></div></div>
|
||||
} else if (this.state.goals) {
|
||||
const hasRevenue = this.state.goals.some((goal) => goal.total_revenue)
|
||||
|
||||
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>Goal</span>
|
||||
<div className="text-right">
|
||||
<span className="inline-block w-20">Uniques</span>
|
||||
<span className="hidden md:inline-block md:w-20">Total</span>
|
||||
<span className="inline-block w-20">CR</span>
|
||||
{hasRevenue && <span className="hidden md:inline-block md:w-20">Revenue</span>}
|
||||
{hasRevenue && <span className="hidden md:inline-block md:w-20">Average</span>}
|
||||
</div>
|
||||
</div>
|
||||
<FlipMove>
|
||||
{ this.state.goals.map((goal) => this.renderGoal.bind(this)(goal, hasRevenue) ) }
|
||||
</FlipMove>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
renderConversions() {
|
||||
return (
|
||||
<div ref={this.htmlNode} style={{ minHeight: '132px', height: this.state.prevHeight ?? 'auto' }} >
|
||||
<LazyLoader onVisible={this.onVisible}>
|
||||
{this.renderInner()}
|
||||
</LazyLoader>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderConversions()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ import classNames from 'classnames'
|
||||
import * as storage from '../../util/storage'
|
||||
|
||||
import Conversions from './conversions'
|
||||
import DeprecatedConversions from './deprecated-conversions'
|
||||
import Properties from './props'
|
||||
import Funnel from './funnel'
|
||||
import { FeatureSetupNotice } from '../../components/notice'
|
||||
@ -123,7 +124,13 @@ export default function Behaviours(props) {
|
||||
}
|
||||
|
||||
function renderConversions() {
|
||||
if (site.hasGoals) { return <Conversions site={site} query={props.query} /> }
|
||||
if (site.hasGoals) {
|
||||
if (site.flags.props) {
|
||||
return <Conversions site={site} query={props.query} />
|
||||
} else {
|
||||
return <DeprecatedConversions site={site} query={props.query} />
|
||||
}
|
||||
}
|
||||
else if (adminAccess) {
|
||||
return (
|
||||
<FeatureSetupNotice
|
||||
|
117
assets/js/dashboard/stats/modals/conversions.js
Normal file
117
assets/js/dashboard/stats/modals/conversions.js
Normal file
@ -0,0 +1,117 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link } from 'react-router-dom'
|
||||
import { withRouter } from 'react-router-dom'
|
||||
import Money from "../behaviours/money";
|
||||
|
||||
import Modal from './modal'
|
||||
import * as api from '../../api'
|
||||
import * as url from "../../util/url";
|
||||
import numberFormatter from '../../util/number-formatter'
|
||||
import {parseQuery} from '../../query'
|
||||
|
||||
function ConversionsModal(props) {
|
||||
const site = props.site
|
||||
const query = parseQuery(props.location.search, site)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [moreResultsAvailable, setMoreResultsAvailable] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [list, setList] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
function fetchData() {
|
||||
api.get(url.apiPath(site, `/conversions`), query, {limit: 100, page})
|
||||
.then((res) => {
|
||||
setLoading(false)
|
||||
setList(list.concat(res))
|
||||
setPage(page + 1)
|
||||
setMoreResultsAvailable(res.length >= 100)
|
||||
})
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
setLoading(true)
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function renderLoadMore() {
|
||||
return (
|
||||
<div className="w-full text-center my-4">
|
||||
<button onClick={loadMore} type="button" className="button">
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function filterSearchLink(listItem) {
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
searchParams.set('goal', listItem.name)
|
||||
return searchParams.toString()
|
||||
}
|
||||
|
||||
function renderListItem(listItem, hasRevenue) {
|
||||
return (
|
||||
<tr className="text-sm dark:text-gray-200" key={listItem.name}>
|
||||
<td className="p-2">
|
||||
<Link
|
||||
to={{pathname: url.siteBasePath(site), search: filterSearchLink(listItem)}}
|
||||
className="hover:underline block truncate">
|
||||
{listItem.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.visitors)}</td>
|
||||
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.events)}</td>
|
||||
<td className="p-2 w-24 font-medium" align="right">{listItem.conversion_rate}%</td>
|
||||
{ hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.total_revenue}/></td> }
|
||||
{ hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.average_revenue}/></td> }
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function renderLoading() {
|
||||
return <div className="loading my-16 mx-auto"><div></div></div>
|
||||
}
|
||||
|
||||
function renderBody() {
|
||||
const hasRevenue = list.some((goal) => goal.total_revenue)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-xl font-bold dark:text-gray-100">Goal Conversions</h1>
|
||||
|
||||
<div className="my-4 border-b border-gray-300"></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 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400 truncate" align="left">Goal</th>
|
||||
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Uniques</th>
|
||||
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Total</th>
|
||||
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>
|
||||
{hasRevenue && <th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Revenue</th>}
|
||||
{hasRevenue && <th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Average</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ list.map((item) => renderListItem(item, hasRevenue)) }
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal site={site}>
|
||||
{ renderBody() }
|
||||
{ loading && renderLoading() }
|
||||
{ !loading && moreResultsAvailable && renderLoadMore() }
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default withRouter(ConversionsModal)
|
@ -190,7 +190,7 @@ export default function ListReport(props) {
|
||||
|
||||
return (
|
||||
<div className="pt-3 w-full text-xs font-bold tracking-wide text-gray-500 flex items-center dark:text-gray-400">
|
||||
<span className="flex-grow">{ props.keyLabel }</span>
|
||||
<span className="flex-grow truncate">{ props.keyLabel }</span>
|
||||
{ metricLabels }
|
||||
</div>
|
||||
)
|
||||
|
@ -1110,6 +1110,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
end
|
||||
|
||||
def conversions(conn, params) do
|
||||
pagination = parse_pagination(params)
|
||||
site = Plausible.Repo.preload(conn.assigns.site, :goals)
|
||||
query = Query.from(site, params) |> Filters.add_prefix()
|
||||
|
||||
@ -1133,7 +1134,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|
||||
conversions =
|
||||
site
|
||||
|> Stats.breakdown(query, "event:goal", metrics, {100, 1})
|
||||
|> Stats.breakdown(query, "event:goal", metrics, pagination)
|
||||
|> transform_keys(%{goal: :name})
|
||||
|> Enum.map(fn goal ->
|
||||
goal
|
||||
|
Loading…
Reference in New Issue
Block a user