Revenue tracking: goal conversions and tracker script (#3019)

* Add revenue average and total to Goal Conversions

* Add revenue option to tracker script

* Simplify revenue tracker script
This commit is contained in:
Vini Brasil 2023-06-14 10:23:11 +01:00 committed by GitHub
parent fd15853965
commit 8f6224b5de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 68 additions and 40 deletions

View File

@ -10,36 +10,27 @@ import * as url from '../../util/url'
import { escapeFilterValue } from '../../util/filters' import { escapeFilterValue } from '../../util/filters'
import LazyLoader from '../../components/lazy-loader' import LazyLoader from '../../components/lazy-loader'
const MOBILE_UPPER_WIDTH = 767 function Money({ formatted }) {
const DEFAULT_WIDTH = 1080 if (formatted) {
return <span tooltip={formatted.long}>{formatted.short}</span>
} else {
return "-"
}
}
export default class Conversions extends React.Component { export default class Conversions extends React.Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.htmlNode = React.createRef() this.htmlNode = React.createRef()
this.state = { this.state = { loading: true, }
loading: true,
viewport: DEFAULT_WIDTH,
}
this.onVisible = this.onVisible.bind(this) this.onVisible = this.onVisible.bind(this)
this.fetchConversions = this.fetchConversions.bind(this) this.fetchConversions = this.fetchConversions.bind(this)
this.handleResize = this.handleResize.bind(this);
}
componentDidMount() {
window.addEventListener('resize', this.handleResize, false);
this.handleResize();
} }
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener('resize', this.handleResize, false);
document.removeEventListener('tick', this.fetchConversions) document.removeEventListener('tick', this.fetchConversions)
} }
handleResize() {
this.setState({ viewport: window.innerWidth });
}
onVisible() { onVisible() {
this.fetchConversions() this.fetchConversions()
if (this.props.query.period === 'realtime') { if (this.props.query.period === 'realtime') {
@ -55,60 +46,60 @@ export default class Conversions extends React.Component {
} }
} }
getBarMaxWidth() {
const { viewport } = this.state;
return viewport > MOBILE_UPPER_WIDTH ? "16rem" : "10rem";
}
fetchConversions() { fetchConversions() {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/conversions`, this.props.query) api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/conversions`, this.props.query)
.then((res) => this.setState({ loading: false, goals: res, prevHeight: null })) .then((res) => this.setState({ loading: false, goals: res, prevHeight: null }))
} }
renderGoal(goal) { renderGoal(goal, renderRevenueColumn) {
const { viewport } = this.state;
const renderProps = this.props.query.filters['goal'] == goal.name && goal.prop_names const renderProps = this.props.query.filters['goal'] == goal.name && goal.prop_names
return ( return (
<div className="my-2 text-sm" key={goal.name}> <div className="my-2 text-sm" key={goal.name}>
<div className="flex items-center justify-between my-2"> <div className="flex items-center justify-between my-2">
<Bar <span className="flex-1">
count={goal.unique_conversions} <Bar
all={this.state.goals} count={goal.unique_conversions}
bg="bg-red-50 dark:bg-gray-500 dark:bg-opacity-15" all={this.state.goals}
maxWidthDeduction={this.getBarMaxWidth()} bg="bg-red-50 dark:bg-gray-500 dark:bg-opacity-15"
plot="unique_conversions" plot="unique_conversions"
> >
<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> <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> </Bar>
</span>
<div className="dark:text-gray-200"> <div className="dark:text-gray-200">
<span className="inline-block w-20 font-medium text-right">{numberFormatter(goal.unique_conversions)}</span> <span className="inline-block w-20 font-medium text-right">{numberFormatter(goal.unique_conversions)}</span>
{viewport > MOBILE_UPPER_WIDTH && <span className="inline-block w-20 font-medium text-right">{numberFormatter(goal.total_conversions)}</span>} <span className="hidden md:inline-block md:w-20 font-medium text-right">{numberFormatter(goal.total_conversions)}</span>
<span className="inline-block w-20 font-medium text-right">{goal.conversion_rate}%</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>
</div> </div>
{renderProps && <PropBreakdown site={this.props.site} query={this.props.query} goal={goal} />} { renderProps && !goal.total_revenue && <PropBreakdown site={this.props.site} query={this.props.query} goal={goal} /> }
</div> </div>
) )
} }
renderInner() { renderInner() {
const { viewport } = this.state;
if (this.state.loading) { if (this.state.loading) {
return <div className="mx-auto my-2 loading"><div></div></div> return <div className="mx-auto my-2 loading"><div></div></div>
} else if (this.state.goals) { } else if (this.state.goals) {
const hasRevenue = this.state.goals.some((goal) => goal.total_revenue)
return ( return (
<React.Fragment> <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"> <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> <span>Goal</span>
<div className="text-right"> <div className="text-right">
<span className="inline-block w-20">Uniques</span> <span className="inline-block w-20">Uniques</span>
{viewport > MOBILE_UPPER_WIDTH && <span className="inline-block w-20">Total</span>} <span className="hidden md:inline-block md:w-20">Total</span>
<span className="inline-block w-20">CR</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>
</div> </div>
<FlipMove> <FlipMove>
{this.state.goals.map(this.renderGoal.bind(this))} { this.state.goals.map((goal) => this.renderGoal.bind(this)(goal, hasRevenue) ) }
</FlipMove> </FlipMove>
</React.Fragment> </React.Fragment>
) )

View File

@ -11,7 +11,8 @@ defmodule PlausibleWeb.Tracker do
"manual", "manual",
"file-downloads", "file-downloads",
"pageview-props", "pageview-props",
"tagged-events" "tagged-events",
"revenue"
] ]
# Generates Power Set of all variants # Generates Power Set of all variants

View File

@ -26,7 +26,7 @@ function compilefile(input, output, templateVars = {}) {
} }
} }
const base_variants = ["hash", "outbound-links", "exclusions", "compat", "local", "manual", "file-downloads", "pageview-props", "tagged-events"] const base_variants = ["hash", "outbound-links", "exclusions", "compat", "local", "manual", "file-downloads", "pageview-props", "tagged-events", "revenue"]
const variants = [...g.clone.powerSet(base_variants)].filter(a => a.length > 0).map(a => a.sort()); const variants = [...g.clone.powerSet(base_variants)].filter(a => a.length > 0).map(a => a.sort());
compilefile(relPath('src/plausible.js'), relPath('../priv/tracker/js/plausible.js')) compilefile(relPath('src/plausible.js'), relPath('../priv/tracker/js/plausible.js'))

View File

@ -81,6 +81,11 @@
if (options && options.props) { if (options && options.props) {
payload.p = options.props payload.p = options.props
} }
{{#if revenue}}
if (options && options.revenue) {
payload.$ = options.revenue
}
{{/if}}
{{#if pageview_props}} {{#if pageview_props}}
var propAttributes = scriptEl.getAttributeNames().filter(function (name) { var propAttributes = scriptEl.getAttributeNames().filter(function (name) {

18
tracker/test/fixtures/revenue.html vendored Normal file
View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Plausible Playwright tests</title>
<script defer src="/tracker/js/plausible.local.manual.revenue.js"></script>
</head>
<body>
<button id="purchase" onclick="window.plausible('Purchase', {revenue: {amount: 15.99, currency: 'USD'}})">
Click me
</button>
</body>
</html>

View File

@ -0,0 +1,13 @@
const { mockRequest, expectCustomEvent } = require('./support/test-utils');
const { expect, test } = require('@playwright/test');
test.describe('with revenue script extension', () => {
test('sends revenue currency and amount', async ({ page }) => {
const plausibleRequestMock = mockRequest(page, '/api/event')
await page.goto('/revenue.html');
await page.click('#purchase')
const plausibleRequest = await plausibleRequestMock
expect(plausibleRequest.postDataJSON()["$"]).toEqual({amount: 15.99, currency: "USD"})
});
});