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 LazyLoader from '../../components/lazy-loader'
const MOBILE_UPPER_WIDTH = 767
const DEFAULT_WIDTH = 1080
function Money({ formatted }) {
if (formatted) {
return <span tooltip={formatted.long}>{formatted.short}</span>
} else {
return "-"
}
}
export default class Conversions extends React.Component {
constructor(props) {
super(props)
this.htmlNode = React.createRef()
this.state = {
loading: true,
viewport: DEFAULT_WIDTH,
}
this.state = { loading: true, }
this.onVisible = this.onVisible.bind(this)
this.fetchConversions = this.fetchConversions.bind(this)
this.handleResize = this.handleResize.bind(this);
}
componentDidMount() {
window.addEventListener('resize', this.handleResize, false);
this.handleResize();
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize, false);
document.removeEventListener('tick', this.fetchConversions)
}
handleResize() {
this.setState({ viewport: window.innerWidth });
}
onVisible() {
this.fetchConversions()
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() {
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) {
const { viewport } = this.state;
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.unique_conversions}
all={this.state.goals}
bg="bg-red-50 dark:bg-gray-500 dark:bg-opacity-15"
maxWidthDeduction={this.getBarMaxWidth()}
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>
</Bar>
</span>
<div className="dark:text-gray-200">
<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>
{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} />}
{ renderProps && !goal.total_revenue && <PropBreakdown site={this.props.site} query={this.props.query} goal={goal} /> }
</div>
)
}
renderInner() {
const { viewport } = this.state;
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>
{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>
{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(this.renderGoal.bind(this))}
{ this.state.goals.map((goal) => this.renderGoal.bind(this)(goal, hasRevenue) ) }
</FlipMove>
</React.Fragment>
)

View File

@ -11,7 +11,8 @@ defmodule PlausibleWeb.Tracker do
"manual",
"file-downloads",
"pageview-props",
"tagged-events"
"tagged-events",
"revenue"
]
# 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());
compilefile(relPath('src/plausible.js'), relPath('../priv/tracker/js/plausible.js'))

View File

@ -81,6 +81,11 @@
if (options && options.props) {
payload.p = options.props
}
{{#if revenue}}
if (options && options.revenue) {
payload.$ = options.revenue
}
{{/if}}
{{#if pageview_props}}
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"})
});
});