mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 17:44:43 +03:00
Implement Funnels view on dashboard (#3066)
* Add Funnel react component assets/js/dashboard/stats/behaviours/funnel.js - restored from: 98a76cbd Remove console.info calls d94db99d Convert Funnel class component into a functional one 028036ad Review comments 3067a940 Stop doing maths in react 73407cc3 Fix error handling when local storage gets corrupted e8c6fc52 Format numbers on funnel labels c815709f Reorganize component responsibility 7a88fe44 Outline basic error handling 94caed7c Chart styling updates 4514608a Add percentages to funnel d622c32d Add funnel picker Co-authored-by: Uku Taht <uku.taht@gmail.com> * Pass funnels list to react via data-funnels * Implement Funnels react API lib/plausible_web/controllers/api/stats_controller.ex - restored from: f36ad234 Adjust to Plausible.Stats interface 9b532273 Test funnel stats controller 028036ad Review comments bea3725f Remove IO.inspect 7a88fe44 Outline basic error handling c8ae3eaf Move Funnels to StatsController and use base query 667cf222 Put private functions at the bottom * Tweak funnel presentation * Handle errors at the top * Do not register DataLabels plugin globally or else all the existing charts are affected * Calculate drop-off percentage evaluating funnels * Tweak dark mode + implement nicer tooltips * Make currently selected funnel bold in the picker * Count user_ids not session_ids when evaluating funnels So if a visitor goes: 1. Start session 2. Complete funnel step 1 3. Inactive for 30 minutes 4. Complete funnel step 2 We would not be able to track this funnel completion because of the session timeout. We like to o measure this as funnel completion even though the session expired in the middle. cc @ukutaht * Add extra properties to the funnels API cc @ukutaht * Improve tooltips so that step to data is rendered * Change tooltip number formatting * Remove debugging remnants * Quick & dirty mobile view * Fix mobile view: tweak dark mode & funnel switching * Ignore DOMException: the operation was aborted Otherwise this sometimes flashes the space shuttle screen when navigating quickly via a keyboard. * Format percentages on the main chart * Close missing tag 🙈 * Revert "Close missing tag 🙈" This reverts commit 9c2f970e22fd7e2980503242b414f42ce8bce1d2. * Use jsx to render funnel tooltip To get markup validated via lsp mostly... * Fixup: s/class/className * Fix className interpolation * Add a ruler to the tooltip * Tweak funnel chart style * Fix font distortion issue on chart/canvas labels * s/class/className * Put "Set up funnels" link behind a feature flag * Refactor internal selection storage Getting ready for live funnel evaluation * Don't try to connect LV socket if there's no CRSF token set up This is perfectly okay for some of the templates/layouts. * Fix up funnel creation typespecs Unfortunately we can't define a type with literal string keys, hence this must suffice. * Use uniq over count/distinct * Revert JSX in tooltips Ref: https://github.com/plausible/analytics/pull/3066#discussion_r1241891155 * Remove the extra query for counting all visitors cc @ukutaht * Add premium notice --------- Co-authored-by: Uku Taht <uku.taht@gmail.com>
This commit is contained in:
parent
a8daac5e03
commit
bbedeff683
90
assets/js/dashboard/stats/behaviours/funnel-tooltip.js
Normal file
90
assets/js/dashboard/stats/behaviours/funnel-tooltip.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
export default function FunnelTooltip(palette, funnel) {
|
||||||
|
return (context) => {
|
||||||
|
const tooltipModel = context.tooltip
|
||||||
|
const dataIndex = tooltipModel.dataPoints[0].dataIndex
|
||||||
|
const offset = document.getElementById("funnel").getBoundingClientRect()
|
||||||
|
let tooltipEl = document.getElementById('chartjs-tooltip')
|
||||||
|
|
||||||
|
if (!tooltipEl) {
|
||||||
|
tooltipEl = document.createElement('div')
|
||||||
|
tooltipEl.id = 'chartjs-tooltip'
|
||||||
|
tooltipEl.style.display = 'none'
|
||||||
|
tooltipEl.style.opacity = 0
|
||||||
|
document.body.appendChild(tooltipEl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tooltipEl && offset && window.innerWidth < 768) {
|
||||||
|
tooltipEl.style.top = offset.y + offset.height + window.scrollY + 15 + 'px'
|
||||||
|
tooltipEl.style.left = offset.x + 'px'
|
||||||
|
tooltipEl.style.right = null
|
||||||
|
tooltipEl.style.opacity = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tooltipModel.opacity === 0) {
|
||||||
|
tooltipEl.style.display = 'none'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (tooltipModel.body) {
|
||||||
|
const currentStep = funnel.steps[dataIndex]
|
||||||
|
const previousStep = (dataIndex > 0) ? funnel.steps[dataIndex - 1] : null
|
||||||
|
|
||||||
|
tooltipEl.innerHTML = `
|
||||||
|
<aside class="text-gray-100 flex flex-col">
|
||||||
|
<div class="flex justify-between items-center border-b-2 border-gray-700 pb-2">
|
||||||
|
<span class="font-semibold mr-4 text-lg">${previousStep ? `<span class="mr-2">${previousStep.label}</span>` : ""}
|
||||||
|
<span class="text-gray-500 mr-2">→</span>
|
||||||
|
${tooltipModel.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="min-w-full mt-2">
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<span class="flex items-center mr-4">
|
||||||
|
<div class="w-3 h-3 mr-1 rounded-full ${palette.visitorsLegendClass}"></div>
|
||||||
|
<span>
|
||||||
|
${dataIndex == 0 ? "Entered the funnel" : "Visitors"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<td class="text-right font-bold px-4">
|
||||||
|
<span>
|
||||||
|
${dataIndex == 0 ? funnel.entering_visitors.toLocaleString() : currentStep.visitors.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right text-sm">
|
||||||
|
<span>
|
||||||
|
${dataIndex == 0 ? formatPercentage(funnel.entering_visitors_percentage) : formatPercentage(currentStep.conversion_rate_step)}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<div class="w-3 h-3 mr-1 rounded-full ${palette.dropoffLegendClass}"></div>
|
||||||
|
<span>
|
||||||
|
${dataIndex == 0 ? "Never entered the funnel" : "Dropoff"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<td class="text-right font-bold px-4">
|
||||||
|
<span>${dataIndex == 0 ? funnel.never_entering_visitors.toLocaleString() : currentStep.dropoff.toLocaleString()}</span>
|
||||||
|
</td >
|
||||||
|
<td class="text-right text-sm">
|
||||||
|
<span>${dataIndex == 0 ? formatPercentage(funnel.never_entering_visitors_percentage) : formatPercentage(currentStep.dropoff_percentage)}%</span>
|
||||||
|
</td>
|
||||||
|
</tr >
|
||||||
|
</table >
|
||||||
|
</aside >
|
||||||
|
`
|
||||||
|
}
|
||||||
|
tooltipEl.style.display = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPercentage = (value) => {
|
||||||
|
const decimalNumber = parseFloat(value);
|
||||||
|
return decimalNumber % 1 === 0 ? decimalNumber.toFixed(0) : value;
|
||||||
|
}
|
@ -1,4 +1,356 @@
|
|||||||
export default function Funnel(_props) {
|
import React, { useEffect, useState, useRef } from 'react'
|
||||||
// TODO
|
import FlipMove from 'react-flip-move';
|
||||||
|
import Chart from 'chart.js/auto'
|
||||||
|
import FunnelTooltip from './funnel-tooltip.js'
|
||||||
|
import ChartDataLabels from 'chartjs-plugin-datalabels'
|
||||||
|
import numberFormatter from '../../util/number-formatter'
|
||||||
|
import Bar from '../bar'
|
||||||
|
|
||||||
|
import RocketIcon from '../modals/rocket-icon'
|
||||||
|
|
||||||
|
import * as api from '../../api'
|
||||||
|
import LazyLoader from '../../components/lazy-loader'
|
||||||
|
|
||||||
|
|
||||||
|
export default function Funnel(props) {
|
||||||
|
console.info('funnah')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const [error, setError] = useState(undefined)
|
||||||
|
const [funnel, setFunnel] = useState(null)
|
||||||
|
const [isSmallScreen, setSmallScreen] = useState(false)
|
||||||
|
const chartRef = useRef(null)
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setLoading(true)
|
||||||
|
fetchFunnel()
|
||||||
|
.then((res) => {
|
||||||
|
setFunnel(res)
|
||||||
|
setError(undefined)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
setError(error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (chartRef.current) {
|
||||||
|
chartRef.current.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [props.query, props.funnelName, visible, isSmallScreen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canvasRef.current && funnel && visible && !isSmallScreen) {
|
||||||
|
initialiseChart()
|
||||||
|
}
|
||||||
|
}, [funnel, visible])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia('(max-width: 600px)')
|
||||||
|
setSmallScreen(mediaQuery.matches)
|
||||||
|
const handleScreenChange = (e) => {
|
||||||
|
setSmallScreen(e.matches);
|
||||||
|
}
|
||||||
|
mediaQuery.addEventListener("change", handleScreenChange);
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener("change", handleScreenChange)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isDarkMode = () => {
|
||||||
|
return document.querySelector('html').classList.contains('dark') || false
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPalette = () => {
|
||||||
|
if (isDarkMode()) {
|
||||||
|
return {
|
||||||
|
dataLabelBackground: 'rgba(25, 30, 56, 0.97)',
|
||||||
|
dataLabelTextColor: 'rgb(243, 244, 246)',
|
||||||
|
visitorsBackground: 'rgb(99, 102, 241)',
|
||||||
|
dropoffBackground: '#2F3949',
|
||||||
|
dropoffStripes: 'rgb(25, 30, 56)',
|
||||||
|
stepNameLegendColor: 'rgb(228, 228, 231)',
|
||||||
|
visitorsLegendClass: 'bg-indigo-500',
|
||||||
|
dropoffLegendClass: 'bg-gray-600',
|
||||||
|
smallBarClass: 'bg-indigo-500'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
dataLabelBackground: 'rgba(25, 30, 56, 0.97)',
|
||||||
|
dataLabelTextColor: 'rgb(243, 244, 246)',
|
||||||
|
visitorsBackground: 'rgb(99, 102, 241)',
|
||||||
|
dropoffBackground: 'rgb(224, 231, 255)',
|
||||||
|
dropoffStripes: 'rgb(255, 255, 255)',
|
||||||
|
stepNameLegendColor: 'rgb(12, 24, 39)',
|
||||||
|
visitorsLegendClass: 'bg-indigo-500',
|
||||||
|
dropoffLegendClass: 'bg-indigo-100',
|
||||||
|
smallBarClass: 'bg-indigo-300'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDataLabel = (visitors, ctx) => {
|
||||||
|
if (ctx.dataset.label === 'Visitors') {
|
||||||
|
const conversionRate = funnel.steps[ctx.dataIndex].conversion_rate
|
||||||
|
return `${formatPercentage(conversionRate)}% \n(${numberFormatter(visitors)} Visitors)`
|
||||||
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const calcOffset = (ctx) => {
|
||||||
|
const conversionRate = parseFloat(funnel.steps[ctx.dataIndex].conversion_rate)
|
||||||
|
if (conversionRate > 90) {
|
||||||
|
return -64
|
||||||
|
} else if (conversionRate > 20) {
|
||||||
|
return -28
|
||||||
|
} else {
|
||||||
|
return 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFunnel = () => {
|
||||||
|
return props.site.funnels.find((funnel) => funnel.name === props.funnelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchFunnel = async () => {
|
||||||
|
const funnelMeta = getFunnel()
|
||||||
|
if (typeof funnelMeta === 'undefined') {
|
||||||
|
throw new Error('Could not fetch the funnel. Perhaps it was deleted?')
|
||||||
|
} else {
|
||||||
|
return api.get(`/api/stats/${encodeURIComponent(props.site.domain)}/funnels/${funnelMeta.id}`, props.query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialiseChart = () => {
|
||||||
|
if (chartRef.current) {
|
||||||
|
chartRef.current.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
const palette = getPalette()
|
||||||
|
|
||||||
|
const createDiagonalPattern = (color1, color2) => {
|
||||||
|
// create a 10x10 px canvas for the pattern's base shape
|
||||||
|
let shape = document.createElement('canvas')
|
||||||
|
shape.width = 10
|
||||||
|
shape.height = 10
|
||||||
|
let c = shape.getContext('2d')
|
||||||
|
|
||||||
|
c.fillStyle = color1
|
||||||
|
c.strokeStyle = color2
|
||||||
|
c.fillRect(0, 0, shape.width, shape.height);
|
||||||
|
|
||||||
|
c.beginPath()
|
||||||
|
c.moveTo(2, 0)
|
||||||
|
c.lineTo(10, 8)
|
||||||
|
c.stroke()
|
||||||
|
|
||||||
|
c.beginPath()
|
||||||
|
c.moveTo(0, 8)
|
||||||
|
c.lineTo(2, 10)
|
||||||
|
c.stroke()
|
||||||
|
|
||||||
|
return c.createPattern(shape, 'repeat')
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = funnel.steps.map((step) => step.label)
|
||||||
|
const stepData = funnel.steps.map((step) => step.visitors)
|
||||||
|
|
||||||
|
const dropOffData = funnel.steps.map((step) => step.dropoff)
|
||||||
|
const ctx = canvasRef.current.getContext("2d")
|
||||||
|
|
||||||
|
// passing those verbatim to make sure canvas rendering picks them up
|
||||||
|
var fontFamily = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'
|
||||||
|
|
||||||
|
var gradient = ctx.createLinearGradient(900, 0, 900, 900);
|
||||||
|
gradient.addColorStop(1, palette.dropoffBackground);
|
||||||
|
gradient.addColorStop(0, palette.visitorsBackground);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Visitors',
|
||||||
|
data: stepData,
|
||||||
|
backgroundColor: gradient,
|
||||||
|
hoverBackgroundColor: gradient,
|
||||||
|
borderRadius: 4,
|
||||||
|
stack: 'Stack 0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Dropoff',
|
||||||
|
data: dropOffData,
|
||||||
|
backgroundColor: createDiagonalPattern(palette.dropoffBackground, palette.dropoffStripes),
|
||||||
|
hoverBackgroundColor: palette.dropoffBackground,
|
||||||
|
borderRadius: 4,
|
||||||
|
stack: 'Stack 0',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
plugins: [ChartDataLabels],
|
||||||
|
type: 'bar',
|
||||||
|
data: data,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
barThickness: 120,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: false,
|
||||||
|
mode: 'index',
|
||||||
|
intersect: true,
|
||||||
|
position: 'average',
|
||||||
|
external: FunnelTooltip(palette, funnel)
|
||||||
|
},
|
||||||
|
datalabels: {
|
||||||
|
formatter: formatDataLabel,
|
||||||
|
anchor: 'end',
|
||||||
|
align: 'end',
|
||||||
|
offset: calcOffset,
|
||||||
|
backgroundColor: palette.dataLabelBackground,
|
||||||
|
color: palette.dataLabelTextColor,
|
||||||
|
borderRadius: 4,
|
||||||
|
clip: true,
|
||||||
|
font: { size: 12, weight: 'normal', lineHeight: 1.6, family: fontFamily },
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: { top: 8, bottom: 8, right: 8, left: 8 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { display: false },
|
||||||
|
x: {
|
||||||
|
position: 'bottom',
|
||||||
|
display: true,
|
||||||
|
border: { display: false },
|
||||||
|
grid: { drawBorder: false, display: false },
|
||||||
|
ticks: {
|
||||||
|
padding: 8,
|
||||||
|
font: { weight: 'bold', family: fontFamily, size: 14 },
|
||||||
|
color: palette.stepNameLegendColor
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
chartRef.current = new Chart(ctx, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between w-full">
|
||||||
|
<h4 className="mt-2 text-sm dark:text-gray-100">{props.funnelName}</h4>
|
||||||
|
{props.tabs}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderError = () => {
|
||||||
|
if (error.name === 'AbortError') return
|
||||||
|
if (error.payload && error.payload.level === 'normal') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header()}
|
||||||
|
<div className="font-medium text-center text-gray-500 mt-44 dark:text-gray-400">{error.message}</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{header()}
|
||||||
|
<div className="text-center text-gray-900 dark:text-gray-100 mt-16">
|
||||||
|
<RocketIcon />
|
||||||
|
<div className="text-lg font-bold">Oops! Something went wrong</div>
|
||||||
|
<div className="text-lg">{error.message ? error.message : 'Failed to render funnel'}</div>
|
||||||
|
<div className="text-xs mt-8">Please try refreshing your browser or selecting the funnel again.</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderInner = () => {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="mx-auto loading pt-44"><div></div></div>
|
||||||
|
} else if (error) {
|
||||||
|
return renderError()
|
||||||
|
} else if (funnel) {
|
||||||
|
const conversionRate = funnel.steps[funnel.steps.length - 1].conversion_rate
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
{header()}
|
||||||
|
<p className="mt-1 text-gray-500 text-sm">{funnel.steps.length}-step funnel • {conversionRate}% conversion rate</p>
|
||||||
|
{isSmallScreen && <div className="mt-4">{renderBars(funnel)}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderBar = (step) => {
|
||||||
|
const palette = getPalette()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between my-1 text-sm">
|
||||||
|
<Bar
|
||||||
|
count={step.visitors}
|
||||||
|
all={funnel.steps}
|
||||||
|
bg={palette.smallBarClass}
|
||||||
|
maxWidthDeduction={"5rem"}
|
||||||
|
plot={'visitors'}
|
||||||
|
>
|
||||||
|
|
||||||
|
<span className="flex px-2 py-1.5 group dark:text-gray-100 relative z-9 break-all">
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
</Bar>
|
||||||
|
|
||||||
|
<span className="font-medium dark:text-gray-200 w-20 text-right" tooltip={step.visitors.toLocaleString()}>
|
||||||
|
{numberFormatter(step.visitors)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderBars = (funnel) => {
|
||||||
|
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> </span>
|
||||||
|
<span className="text-right">
|
||||||
|
<span className="inline-block w-20">Visitors</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<FlipMove>
|
||||||
|
{funnel.steps.map(renderBar)}
|
||||||
|
</FlipMove>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPercentage = (value) => {
|
||||||
|
const decimalNumber = parseFloat(value);
|
||||||
|
return decimalNumber % 1 === 0 ? decimalNumber.toFixed(0) : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '400px' }}>
|
||||||
|
<LazyLoader onVisible={() => setVisible(true)}>
|
||||||
|
{renderInner()}
|
||||||
|
</LazyLoader>
|
||||||
|
{!isSmallScreen && <canvas className="" id="funnel" ref={canvasRef}></canvas>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -82,7 +82,7 @@ export default function Behaviours(props) {
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
active ? 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer' : 'text-gray-700 dark:text-gray-200',
|
active ? 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer' : 'text-gray-700 dark:text-gray-200',
|
||||||
'block px-4 py-2 text-sm',
|
'block px-4 py-2 text-sm',
|
||||||
mode === funnelName ? 'font-bold' : ''
|
selectedFunnel === funnelName ? 'font-bold text-gray-500' : ''
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{funnelName}
|
{funnelName}
|
||||||
|
@ -2,9 +2,11 @@ import "phoenix_html"
|
|||||||
import { Socket } from "phoenix"
|
import { Socket } from "phoenix"
|
||||||
import { LiveSocket } from "phoenix_live_view"
|
import { LiveSocket } from "phoenix_live_view"
|
||||||
|
|
||||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
let csrfToken = document.querySelector("meta[name='csrf-token']")
|
||||||
|
if (csrfToken) {
|
||||||
|
let token = csrfToken.getAttribute("content")
|
||||||
let liveSocket = new LiveSocket("/live", Socket, {
|
let liveSocket = new LiveSocket("/live", Socket, {
|
||||||
params: { _csrf_token: csrfToken }, hooks: {}, dom: {
|
params: { _csrf_token: token }, hooks: {}, dom: {
|
||||||
// for alpinejs integration
|
// for alpinejs integration
|
||||||
onBeforeElUpdated(from, to) {
|
onBeforeElUpdated(from, to) {
|
||||||
if (from.__x) {
|
if (from.__x) {
|
||||||
@ -16,3 +18,4 @@ let liveSocket = new LiveSocket("/live", Socket, {
|
|||||||
|
|
||||||
liveSocket.connect()
|
liveSocket.connect()
|
||||||
window.liveSocket = liveSocket
|
window.liveSocket = liveSocket
|
||||||
|
}
|
||||||
|
@ -18,7 +18,7 @@ defmodule Plausible.Funnels do
|
|||||||
FunWithFlags.enabled?(:funnels, for: actor)
|
FunWithFlags.enabled?(:funnels, for: actor)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec create(Plausible.Site.t(), String.t(), [Plausible.Goal.t()]) ::
|
@spec create(Plausible.Site.t(), String.t(), [map()]) ::
|
||||||
{:ok, Funnel.t()}
|
{:ok, Funnel.t()}
|
||||||
| {:error, Ecto.Changeset.t() | :invalid_funnel_size}
|
| {:error, Ecto.Changeset.t() | :invalid_funnel_size}
|
||||||
def create(site, name, steps)
|
def create(site, name, steps)
|
||||||
@ -32,13 +32,13 @@ defmodule Plausible.Funnels do
|
|||||||
{:error, :invalid_funnel_size}
|
{:error, :invalid_funnel_size}
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec create_changeset(Plausible.Site.t(), String.t(), [Plausible.Goal.t()]) ::
|
@spec create_changeset(Plausible.Site.t(), String.t(), [map()]) ::
|
||||||
Ecto.Changeset.t()
|
Ecto.Changeset.t()
|
||||||
def create_changeset(site, name, steps) do
|
def create_changeset(site, name, steps) do
|
||||||
Funnel.changeset(%Funnel{site_id: site.id}, %{name: name, steps: steps})
|
Funnel.changeset(%Funnel{site_id: site.id}, %{name: name, steps: steps})
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec ephemeral_definition(Plausible.Site.t(), String.t(), [Plausible.Goal.t()]) :: Funnel.t()
|
@spec ephemeral_definition(Plausible.Site.t(), String.t(), [map()]) :: Funnel.t()
|
||||||
def ephemeral_definition(site, name, steps) do
|
def ephemeral_definition(site, name, steps) do
|
||||||
site
|
site
|
||||||
|> create_changeset(name, steps)
|
|> create_changeset(name, steps)
|
||||||
|
@ -15,7 +15,7 @@ defmodule Plausible.Stats.Funnel do
|
|||||||
alias Plausible.Stats.Base
|
alias Plausible.Stats.Base
|
||||||
|
|
||||||
@spec funnel(Plausible.Site.t(), Plausible.Stats.Query.t(), Funnel.t() | pos_integer()) ::
|
@spec funnel(Plausible.Site.t(), Plausible.Stats.Query.t(), Funnel.t() | pos_integer()) ::
|
||||||
{:ok, Funnel.t()} | {:error, :funnel_not_found}
|
{:ok, map()} | {:error, :funnel_not_found}
|
||||||
def funnel(site, query, funnel_id) when is_integer(funnel_id) do
|
def funnel(site, query, funnel_id) when is_integer(funnel_id) do
|
||||||
case Funnels.get(site.id, funnel_id) do
|
case Funnels.get(site.id, funnel_id) do
|
||||||
%Funnel{} = funnel ->
|
%Funnel{} = funnel ->
|
||||||
@ -27,34 +27,51 @@ defmodule Plausible.Stats.Funnel do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def funnel(site, query, %Funnel{} = funnel) do
|
def funnel(site, query, %Funnel{} = funnel) do
|
||||||
steps =
|
funnel_data =
|
||||||
site
|
site
|
||||||
|> Base.base_event_query(query)
|
|> Base.base_event_query(query)
|
||||||
|> query_funnel(funnel)
|
|> query_funnel(funnel)
|
||||||
|> backfill_steps(funnel)
|
|
||||||
|
# Funnel definition steps are 1-indexed, if there's index 0 in the resulting query,
|
||||||
|
# it signifies the number of visitors that haven't entered the funnel.
|
||||||
|
not_entering_visitors =
|
||||||
|
case funnel_data do
|
||||||
|
[{0, count} | _] -> count
|
||||||
|
_ -> 0
|
||||||
|
end
|
||||||
|
|
||||||
|
all_visitors = Enum.reduce(funnel_data, 0, fn {_, n}, acc -> acc + n end)
|
||||||
|
steps = backfill_steps(funnel_data, funnel)
|
||||||
|
|
||||||
|
visitors_at_first_step = List.first(steps).visitors
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
%{
|
%{
|
||||||
name: funnel.name,
|
name: funnel.name,
|
||||||
steps: steps
|
steps: steps,
|
||||||
|
all_visitors: all_visitors,
|
||||||
|
entering_visitors: visitors_at_first_step,
|
||||||
|
entering_visitors_percentage: percentage(visitors_at_first_step, all_visitors),
|
||||||
|
never_entering_visitors: all_visitors - visitors_at_first_step,
|
||||||
|
never_entering_visitors_percentage: percentage(not_entering_visitors, all_visitors)
|
||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp query_funnel(query, funnel_definition) do
|
defp query_funnel(query, funnel_definition) do
|
||||||
q_events =
|
q_events =
|
||||||
from(e in query,
|
from(e in query,
|
||||||
select: %{session_id: e.session_id},
|
select: %{user_id: e.user_id},
|
||||||
where: e.site_id == ^funnel_definition.site_id,
|
where: e.site_id == ^funnel_definition.site_id,
|
||||||
group_by: e.session_id,
|
group_by: e.user_id,
|
||||||
having: fragment("step > 0"),
|
|
||||||
order_by: [desc: fragment("step")]
|
order_by: [desc: fragment("step")]
|
||||||
)
|
)
|
||||||
|> select_funnel(funnel_definition)
|
|> select_funnel(funnel_definition)
|
||||||
|
|
||||||
query =
|
query =
|
||||||
from f in subquery(q_events),
|
from(f in subquery(q_events),
|
||||||
select: {f.step, count(1)},
|
select: {f.step, count(1)},
|
||||||
group_by: f.step
|
group_by: f.step
|
||||||
|
)
|
||||||
|
|
||||||
ClickhouseRepo.all(query)
|
ClickhouseRepo.all(query)
|
||||||
end
|
end
|
||||||
@ -62,12 +79,12 @@ defmodule Plausible.Stats.Funnel do
|
|||||||
defp select_funnel(db_query, funnel_definition) do
|
defp select_funnel(db_query, funnel_definition) do
|
||||||
window_funnel_steps =
|
window_funnel_steps =
|
||||||
Enum.reduce(funnel_definition.steps, nil, fn step, acc ->
|
Enum.reduce(funnel_definition.steps, nil, fn step, acc ->
|
||||||
step_condition = step_condition(step.goal)
|
goal_condition = goal_condition(step.goal)
|
||||||
|
|
||||||
if acc do
|
if acc do
|
||||||
dynamic([q], fragment("?, ?", ^acc, ^step_condition))
|
dynamic([q], fragment("?, ?", ^acc, ^goal_condition))
|
||||||
else
|
else
|
||||||
dynamic([q], fragment("?", ^step_condition))
|
dynamic([q], fragment("?", ^goal_condition))
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@ -77,14 +94,15 @@ defmodule Plausible.Stats.Funnel do
|
|||||||
fragment("windowFunnel(?)(timestamp, ?)", @funnel_window_duration, ^window_funnel_steps)
|
fragment("windowFunnel(?)(timestamp, ?)", @funnel_window_duration, ^window_funnel_steps)
|
||||||
)
|
)
|
||||||
|
|
||||||
from q in db_query,
|
from(q in db_query,
|
||||||
select_merge:
|
select_merge:
|
||||||
^%{
|
^%{
|
||||||
step: dynamic_window_funnel
|
step: dynamic_window_funnel
|
||||||
}
|
}
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp step_condition(goal) do
|
defp goal_condition(goal) do
|
||||||
case goal do
|
case goal do
|
||||||
%Plausible.Goal{event_name: event} when is_binary(event) ->
|
%Plausible.Goal{event_name: event} when is_binary(event) ->
|
||||||
dynamic([], fragment("name = ?", ^event))
|
dynamic([], fragment("name = ?", ^event))
|
||||||
@ -104,6 +122,9 @@ defmodule Plausible.Stats.Funnel do
|
|||||||
# but no totals including previous steps are aggregated.
|
# but no totals including previous steps are aggregated.
|
||||||
# Hence we need to perform the appropriate backfill
|
# Hence we need to perform the appropriate backfill
|
||||||
# and also calculate dropoff and conversion rate for each step.
|
# and also calculate dropoff and conversion rate for each step.
|
||||||
|
# In case ClickHouse returns 0-index funnel result, we're going to ignore it
|
||||||
|
# anyway, since we fold over steps as per definition, that are always
|
||||||
|
# indexed starting from 1.
|
||||||
funnel_result = Enum.into(funnel_result, %{})
|
funnel_result = Enum.into(funnel_result, %{})
|
||||||
max_step = Enum.max_by(funnel.steps, & &1.step_order).step_order
|
max_step = Enum.max_by(funnel.steps, & &1.step_order).step_order
|
||||||
|
|
||||||
@ -128,20 +149,15 @@ defmodule Plausible.Stats.Funnel do
|
|||||||
# Dropoff is 0 for the first step, otherwise we subtract current from previous
|
# Dropoff is 0 for the first step, otherwise we subtract current from previous
|
||||||
dropoff = if visitors_at_previous, do: visitors_at_previous - current_visitors, else: 0
|
dropoff = if visitors_at_previous, do: visitors_at_previous - current_visitors, else: 0
|
||||||
|
|
||||||
conversion_rate =
|
dropoff_percentage = percentage(dropoff, visitors_at_previous)
|
||||||
if current_visitors == 0 or total_visitors == 0 do
|
conversion_rate = percentage(current_visitors, total_visitors)
|
||||||
"0.00"
|
conversion_rate_step = percentage(current_visitors, visitors_at_previous)
|
||||||
else
|
|
||||||
current_visitors
|
|
||||||
|> Decimal.div(total_visitors)
|
|
||||||
|> Decimal.mult(100)
|
|
||||||
|> Decimal.round(2)
|
|
||||||
|> Decimal.to_string()
|
|
||||||
end
|
|
||||||
|
|
||||||
step = %{
|
step = %{
|
||||||
dropoff: dropoff,
|
dropoff: dropoff,
|
||||||
|
dropoff_percentage: dropoff_percentage,
|
||||||
conversion_rate: conversion_rate,
|
conversion_rate: conversion_rate,
|
||||||
|
conversion_rate_step: conversion_rate_step,
|
||||||
visitors: visitors_at_step,
|
visitors: visitors_at_step,
|
||||||
label: to_string(step.goal)
|
label: to_string(step.goal)
|
||||||
}
|
}
|
||||||
@ -151,4 +167,16 @@ defmodule Plausible.Stats.Funnel do
|
|||||||
|> elem(2)
|
|> elem(2)
|
||||||
|> Enum.reverse()
|
|> Enum.reverse()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp percentage(x, y) when x in [0, nil] or y in [0, nil] do
|
||||||
|
"0.00"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp percentage(x, y) do
|
||||||
|
x
|
||||||
|
|> Decimal.div(y)
|
||||||
|
|> Decimal.mult(100)
|
||||||
|
|> Decimal.round(2)
|
||||||
|
|> Decimal.to_string()
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -7,7 +7,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
|||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
plug :validate_common_input
|
plug(:validate_common_input)
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a time-series based on given parameters.
|
Returns a time-series based on given parameters.
|
||||||
@ -506,6 +506,52 @@ defmodule PlausibleWeb.Api.StatsController do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def funnel(conn, %{"id" => funnel_id} = params) do
|
||||||
|
site = conn.assigns[:site]
|
||||||
|
|
||||||
|
with :ok <- validate_params(params),
|
||||||
|
query <- Query.from(site, params) |> Filters.add_prefix(),
|
||||||
|
:ok <- validate_funnel_query(query),
|
||||||
|
{funnel_id, ""} <- Integer.parse(funnel_id),
|
||||||
|
{:ok, funnel} <- Stats.funnel(site, query, funnel_id) do
|
||||||
|
json(conn, funnel)
|
||||||
|
else
|
||||||
|
{:error, {:invalid_funnel_query, due_to}} ->
|
||||||
|
bad_request(
|
||||||
|
conn,
|
||||||
|
"We are unable to show funnels when the dashboard is filtered by #{due_to}",
|
||||||
|
%{
|
||||||
|
level: :normal
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, :funnel_not_found} ->
|
||||||
|
conn
|
||||||
|
|> put_status(404)
|
||||||
|
|> json(%{error: "Funnel not found"})
|
||||||
|
|> halt()
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
bad_request(conn, "There was an error with your request")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_funnel_query(query) do
|
||||||
|
case query do
|
||||||
|
_ when is_map_key(query.filters, "event:goal") ->
|
||||||
|
{:error, {:invalid_funnel_query, "goals"}}
|
||||||
|
|
||||||
|
_ when is_map_key(query.filters, "event:page") ->
|
||||||
|
{:error, {:invalid_funnel_query, "pages"}}
|
||||||
|
|
||||||
|
_ when query.period == "realtime" ->
|
||||||
|
{:error, {:invalid_funnel_query, "realtime period"}}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def utm_mediums(conn, params) do
|
def utm_mediums(conn, params) do
|
||||||
site = conn.assigns[:site]
|
site = conn.assigns[:site]
|
||||||
|
|
||||||
@ -1339,10 +1385,12 @@ defmodule PlausibleWeb.Api.StatsController do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp bad_request(conn, message) do
|
defp bad_request(conn, message, extra \\ %{}) do
|
||||||
|
payload = Map.merge(extra, %{error: message})
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_status(400)
|
|> put_status(400)
|
||||||
|> json(%{error: message})
|
|> json(payload)
|
||||||
|> halt()
|
|> halt()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@ defmodule PlausibleWeb.StatsController do
|
|||||||
|> render("stats.html",
|
|> render("stats.html",
|
||||||
site: site,
|
site: site,
|
||||||
has_goals: Plausible.Sites.has_goals?(site),
|
has_goals: Plausible.Sites.has_goals?(site),
|
||||||
funnels: [],
|
funnels: Plausible.Funnels.list(site),
|
||||||
stats_start_date: stats_start_date,
|
stats_start_date: stats_start_date,
|
||||||
native_stats_start_date: NaiveDateTime.to_date(site.native_stats_start_at),
|
native_stats_start_date: NaiveDateTime.to_date(site.native_stats_start_at),
|
||||||
title: "Plausible · " <> site.domain,
|
title: "Plausible · " <> site.domain,
|
||||||
@ -292,7 +292,7 @@ defmodule PlausibleWeb.StatsController do
|
|||||||
|> render("stats.html",
|
|> render("stats.html",
|
||||||
site: shared_link.site,
|
site: shared_link.site,
|
||||||
has_goals: Sites.has_goals?(shared_link.site),
|
has_goals: Sites.has_goals?(shared_link.site),
|
||||||
funnels: [],
|
funnels: Plausible.Funnels.list(shared_link.site),
|
||||||
stats_start_date: shared_link.site.stats_start_date,
|
stats_start_date: shared_link.site.stats_start_date,
|
||||||
native_stats_start_date: NaiveDateTime.to_date(shared_link.site.native_stats_start_at),
|
native_stats_start_date: NaiveDateTime.to_date(shared_link.site.native_stats_start_at),
|
||||||
title: "Plausible · " <> shared_link.site.domain,
|
title: "Plausible · " <> shared_link.site.domain,
|
||||||
|
@ -31,7 +31,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||||||
form: to_form(Plausible.Funnels.create_changeset(site, "", [])),
|
form: to_form(Plausible.Funnels.create_changeset(site, "", [])),
|
||||||
goals: goals,
|
goals: goals,
|
||||||
site: site,
|
site: site,
|
||||||
already_selected: Map.new()
|
selections_made: Map.new()
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -63,7 +63,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||||||
submit_name="funnel[steps][][goal_id]"
|
submit_name="funnel[steps][][goal_id]"
|
||||||
module={PlausibleWeb.Live.FunnelSettings.ComboBox}
|
module={PlausibleWeb.Live.FunnelSettings.ComboBox}
|
||||||
id={"step-#{step_idx}"}
|
id={"step-#{step_idx}"}
|
||||||
options={reject_alrady_selected("step-#{step_idx}", @goals, @already_selected)}
|
options={reject_alrady_selected("step-#{step_idx}", @goals, @selections_made)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -72,7 +72,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||||||
|
|
||||||
<.add_step_button :if={
|
<.add_step_button :if={
|
||||||
length(@step_ids) < Funnel.max_steps() and
|
length(@step_ids) < Funnel.max_steps() and
|
||||||
map_size(@already_selected) < length(@goals)
|
map_size(@selections_made) < length(@goals)
|
||||||
} />
|
} />
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
@ -202,13 +202,9 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||||||
def handle_event("remove-step", %{"step-idx" => idx}, socket) do
|
def handle_event("remove-step", %{"step-idx" => idx}, socket) do
|
||||||
idx = String.to_integer(idx)
|
idx = String.to_integer(idx)
|
||||||
step_ids = List.delete(socket.assigns.step_ids, idx)
|
step_ids = List.delete(socket.assigns.step_ids, idx)
|
||||||
already_selected = socket.assigns.already_selected
|
selections_made = drop_selection(socket.assigns.selections_made, idx)
|
||||||
|
|
||||||
step_input_id = "step-#{idx}"
|
{:noreply, assign(socket, step_ids: step_ids, selections_made: selections_made)}
|
||||||
|
|
||||||
new_already_selected = Map.delete(already_selected, step_input_id)
|
|
||||||
|
|
||||||
{:noreply, assign(socket, step_ids: step_ids, already_selected: new_already_selected)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("validate", %{"funnel" => params}, socket) do
|
def handle_event("validate", %{"funnel" => params}, socket) do
|
||||||
@ -242,14 +238,12 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:selection_made, %{submit_value: goal_id, by: combo_box}}, socket) do
|
def handle_info({:selection_made, %{submit_value: goal_id, by: combo_box}}, socket) do
|
||||||
already_selected = Map.put(socket.assigns.already_selected, combo_box, goal_id)
|
selections_made = store_selection(socket.assigns, combo_box, goal_id)
|
||||||
{:noreply, assign(socket, already_selected: already_selected)}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp reject_alrady_selected(combo_box, goals, already_selected) do
|
{:noreply,
|
||||||
result = Enum.reject(goals, fn {goal_id, _} -> goal_id in Map.values(already_selected) end)
|
assign(socket,
|
||||||
send_update(PlausibleWeb.Live.FunnelSettings.ComboBox, id: combo_box, suggestions: result)
|
selections_made: selections_made
|
||||||
result
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp find_sequence_break(input) do
|
defp find_sequence_break(input) do
|
||||||
@ -268,4 +262,34 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
|||||||
defp has_steps_errors?(f) do
|
defp has_steps_errors?(f) do
|
||||||
not f.source.valid?
|
not f.source.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp get_goal(assigns, id) do
|
||||||
|
assigns
|
||||||
|
|> Map.fetch!(:goals)
|
||||||
|
|> Enum.find_value(fn
|
||||||
|
{goal_id, goal} when goal_id == id -> goal
|
||||||
|
_ -> nil
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp store_selection(assigns, combo_box, goal_id) do
|
||||||
|
Map.put(assigns.selections_made, combo_box, get_goal(assigns, goal_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp drop_selection(selections_made, step_idx) do
|
||||||
|
step_input_id = "step-#{step_idx}"
|
||||||
|
Map.delete(selections_made, step_input_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp reject_alrady_selected(combo_box, goals, selections_made) do
|
||||||
|
selection_ids =
|
||||||
|
Enum.map(selections_made, fn
|
||||||
|
{_, %{id: goal_id}} -> goal_id
|
||||||
|
end)
|
||||||
|
|
||||||
|
result = Enum.reject(goals, fn {goal_id, _} -> goal_id in selection_ids end)
|
||||||
|
|
||||||
|
send_update(PlausibleWeb.Live.FunnelSettings.ComboBox, id: combo_box, suggestions: result)
|
||||||
|
result
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -59,7 +59,7 @@ defmodule PlausibleWeb.Router do
|
|||||||
|
|
||||||
scope "/api/stats", PlausibleWeb.Api do
|
scope "/api/stats", PlausibleWeb.Api do
|
||||||
pipe_through :internal_stats_api
|
pipe_through :internal_stats_api
|
||||||
|
get "/:domain//funnels/:id", StatsController, :funnel
|
||||||
get "/:domain/current-visitors", StatsController, :current_visitors
|
get "/:domain/current-visitors", StatsController, :current_visitors
|
||||||
get "/:domain/main-graph", StatsController, :main_graph
|
get "/:domain/main-graph", StatsController, :main_graph
|
||||||
get "/:domain/top-stats", StatsController, :top_stats
|
get "/:domain/top-stats", StatsController, :top_stats
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">
|
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">
|
||||||
Compose goals into funnels
|
Compose goals into funnels
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<%= link(to: "https://plausible.io/docs/funnel-analysis", target: "_blank", rel: "noreferrer") do %>
|
<%= link(to: "https://plausible.io/docs/funnel-analysis", target: "_blank", rel: "noreferrer") do %>
|
||||||
<svg
|
<svg
|
||||||
class="w-6 h-6 absolute top-0 right-0 text-gray-400"
|
class="w-6 h-6 absolute top-0 right-0 text-gray-400"
|
||||||
@ -27,6 +28,15 @@
|
|||||||
label="Show funnels in the dashboard"
|
label="Show funnels in the dashboard"
|
||||||
conn={@conn}
|
conn={@conn}
|
||||||
>
|
>
|
||||||
|
<PlausibleWeb.Components.Generic.notice>
|
||||||
|
Funnels is an upcoming premium feature that's free-to-use
|
||||||
|
during the private preview. Pricing will be announced soon. See
|
||||||
|
examples and learn more in <a
|
||||||
|
class="font-medium text-yellow underline hover:text-yellow-600"
|
||||||
|
href="https://plausible.io/docs/funnel-analysis"
|
||||||
|
>our docs</a>.
|
||||||
|
</PlausibleWeb.Components.Generic.notice>
|
||||||
|
|
||||||
<%= live_render(@conn, PlausibleWeb.Live.FunnelSettings,
|
<%= live_render(@conn, PlausibleWeb.Live.FunnelSettings,
|
||||||
session: %{"site_id" => @site.id, "domain" => @site.domain},
|
session: %{"site_id" => @site.id, "domain" => @site.domain},
|
||||||
router: PlausibleWeb.Router
|
router: PlausibleWeb.Router
|
||||||
|
@ -86,7 +86,7 @@
|
|||||||
to: "/#{URI.encode_www_form(@site.domain)}/goals/new",
|
to: "/#{URI.encode_www_form(@site.domain)}/goals/new",
|
||||||
class: "button mt-6"
|
class: "button mt-6"
|
||||||
) %>
|
) %>
|
||||||
<%= if Enum.count(@goals) >= Funnel.min_steps() do %>
|
<%= if Enum.count(@goals) >= Funnel.min_steps() and Plausible.Funnels.enabled_for?(@current_user) do %>
|
||||||
<%= link("Set up funnels",
|
<%= link("Set up funnels",
|
||||||
to: Routes.site_path(@conn, :settings_funnels, @site.domain),
|
to: Routes.site_path(@conn, :settings_funnels, @site.domain),
|
||||||
class: "mt-6 ml-2 text-indigo-500 underline text-sm"
|
class: "mt-6 ml-2 text-indigo-500 underline text-sm"
|
||||||
|
@ -155,6 +155,7 @@ defmodule Plausible.FunnelsTest do
|
|||||||
)
|
)
|
||||||
|
|
||||||
populate_stats(site, [
|
populate_stats(site, [
|
||||||
|
build(:pageview, pathname: "/irrelevant/page/not/in/funnel", user_id: 999),
|
||||||
build(:pageview, pathname: "/go/to/blog/foo", user_id: 123),
|
build(:pageview, pathname: "/go/to/blog/foo", user_id: 123),
|
||||||
build(:event, name: "Signup", user_id: 123),
|
build(:event, name: "Signup", user_id: 123),
|
||||||
build(:pageview, pathname: "/checkout", user_id: 123),
|
build(:pageview, pathname: "/checkout", user_id: 123),
|
||||||
@ -168,15 +169,36 @@ defmodule Plausible.FunnelsTest do
|
|||||||
|
|
||||||
assert {:ok,
|
assert {:ok,
|
||||||
%{
|
%{
|
||||||
|
all_visitors: 3,
|
||||||
|
entering_visitors: 2,
|
||||||
|
entering_visitors_percentage: "66.67",
|
||||||
|
never_entering_visitors: 1,
|
||||||
|
never_entering_visitors_percentage: "33.33",
|
||||||
steps: [
|
steps: [
|
||||||
%{
|
%{
|
||||||
label: "Visit /go/to/blog/**",
|
label: "Visit /go/to/blog/**",
|
||||||
visitors: 2,
|
visitors: 2,
|
||||||
conversion_rate: "100.00",
|
conversion_rate: "100.00",
|
||||||
dropoff: 0
|
conversion_rate_step: "0.00",
|
||||||
|
dropoff: 0,
|
||||||
|
dropoff_percentage: "0.00"
|
||||||
},
|
},
|
||||||
%{label: "Signup", visitors: 2, conversion_rate: "100.00", dropoff: 0},
|
%{
|
||||||
%{label: "Visit /checkout", visitors: 1, conversion_rate: "50.00", dropoff: 1}
|
label: "Signup",
|
||||||
|
visitors: 2,
|
||||||
|
conversion_rate: "100.00",
|
||||||
|
conversion_rate_step: "100.00",
|
||||||
|
dropoff: 0,
|
||||||
|
dropoff_percentage: "0.00"
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
label: "Visit /checkout",
|
||||||
|
visitors: 1,
|
||||||
|
conversion_rate: "50.00",
|
||||||
|
conversion_rate_step: "50.00",
|
||||||
|
dropoff: 1,
|
||||||
|
dropoff_percentage: "50.00"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}} = funnel_data
|
}} = funnel_data
|
||||||
end
|
end
|
||||||
@ -206,15 +228,35 @@ defmodule Plausible.FunnelsTest do
|
|||||||
|
|
||||||
assert {:ok,
|
assert {:ok,
|
||||||
%{
|
%{
|
||||||
|
all_visitors: 2,
|
||||||
|
entering_visitors: 2,
|
||||||
|
entering_visitors_percentage: "100.00",
|
||||||
|
never_entering_visitors: 0,
|
||||||
|
never_entering_visitors_percentage: "0.00",
|
||||||
steps: [
|
steps: [
|
||||||
%{
|
%{
|
||||||
label: "Visit /go/to/blog/**",
|
label: "Visit /go/to/blog/**",
|
||||||
visitors: 2,
|
visitors: 2,
|
||||||
conversion_rate: "100.00",
|
conversion_rate: "100.00",
|
||||||
|
conversion_rate_step: "0.00",
|
||||||
dropoff: 0
|
dropoff: 0
|
||||||
},
|
},
|
||||||
%{label: "Signup", visitors: 2, conversion_rate: "100.00", dropoff: 0},
|
%{
|
||||||
%{label: "Visit /checkout", visitors: 1, conversion_rate: "50.00", dropoff: 1}
|
label: "Signup",
|
||||||
|
visitors: 2,
|
||||||
|
conversion_rate: "100.00",
|
||||||
|
conversion_rate_step: "100.00",
|
||||||
|
dropoff: 0,
|
||||||
|
dropoff_percentage: "0.00"
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
label: "Visit /checkout",
|
||||||
|
visitors: 1,
|
||||||
|
conversion_rate: "50.00",
|
||||||
|
conversion_rate_step: "50.00",
|
||||||
|
dropoff: 1,
|
||||||
|
dropoff_percentage: "50.00"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}} = funnel_data
|
}} = funnel_data
|
||||||
end
|
end
|
||||||
@ -236,15 +278,36 @@ defmodule Plausible.FunnelsTest do
|
|||||||
|
|
||||||
assert {:ok,
|
assert {:ok,
|
||||||
%{
|
%{
|
||||||
|
all_visitors: 0,
|
||||||
|
entering_visitors: 0,
|
||||||
|
entering_visitors_percentage: "0.00",
|
||||||
|
never_entering_visitors: 0,
|
||||||
|
never_entering_visitors_percentage: "0.00",
|
||||||
steps: [
|
steps: [
|
||||||
%{
|
%{
|
||||||
label: "Visit /go/to/blog/**",
|
label: "Visit /go/to/blog/**",
|
||||||
visitors: 0,
|
visitors: 0,
|
||||||
conversion_rate: "0.00",
|
conversion_rate: "0.00",
|
||||||
dropoff: 0
|
conversion_rate_step: "0.00",
|
||||||
|
dropoff: 0,
|
||||||
|
dropoff_percentage: "0.00"
|
||||||
},
|
},
|
||||||
%{label: "Signup", visitors: 0, conversion_rate: "0.00", dropoff: 0},
|
%{
|
||||||
%{label: "Visit /checkout", visitors: 0, conversion_rate: "0.00", dropoff: 0}
|
label: "Signup",
|
||||||
|
visitors: 0,
|
||||||
|
conversion_rate: "0.00",
|
||||||
|
conversion_rate_step: "0.00",
|
||||||
|
dropoff: 0,
|
||||||
|
dropoff_percentage: "0.00"
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
label: "Visit /checkout",
|
||||||
|
visitors: 0,
|
||||||
|
conversion_rate: "0.00",
|
||||||
|
conversion_rate_step: "0.00",
|
||||||
|
dropoff: 0,
|
||||||
|
dropoff_percentage: "0.00"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}} = funnel_data
|
}} = funnel_data
|
||||||
end
|
end
|
||||||
|
@ -0,0 +1,289 @@
|
|||||||
|
defmodule PlausibleWeb.Api.StatsController.FunnelsTest do
|
||||||
|
use PlausibleWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
@user_id 123
|
||||||
|
@other_user_id 456
|
||||||
|
|
||||||
|
@build_funnel_with [
|
||||||
|
{"page_path", "/blog/announcement"},
|
||||||
|
{"event_name", "Signup"},
|
||||||
|
{"page_path", "/cart/add/product"},
|
||||||
|
{"event_name", "Purchase"}
|
||||||
|
]
|
||||||
|
|
||||||
|
describe "GET /api/stats/funnel - default" do
|
||||||
|
setup [:create_user, :log_in, :create_new_site]
|
||||||
|
|
||||||
|
test "computes funnel for a day", %{conn: conn, site: site} do
|
||||||
|
{:ok, funnel} = setup_funnel(site, @build_funnel_with)
|
||||||
|
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, pathname: "/some/irrelevant", user_id: 9_999_999),
|
||||||
|
build(:pageview, pathname: "/blog/announcement", user_id: @user_id),
|
||||||
|
build(:pageview, pathname: "/blog/announcement", user_id: @other_user_id),
|
||||||
|
build(:event, name: "Signup", user_id: @user_id),
|
||||||
|
build(:event, name: "Signup", user_id: @other_user_id),
|
||||||
|
build(:pageview, pathname: "/cart/add/product", user_id: @user_id),
|
||||||
|
build(:event, name: "Purchase", user_id: @user_id)
|
||||||
|
])
|
||||||
|
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> get("/api/stats/#{site.domain}/funnels/#{funnel.id}/?period=day")
|
||||||
|
|> json_response(200)
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
"name" => "Test funnel",
|
||||||
|
"all_visitors" => 3,
|
||||||
|
"entering_visitors" => 2,
|
||||||
|
"entering_visitors_percentage" => "66.67",
|
||||||
|
"never_entering_visitors" => 1,
|
||||||
|
"never_entering_visitors_percentage" => "33.33",
|
||||||
|
"steps" => [
|
||||||
|
%{
|
||||||
|
"conversion_rate" => "100.00",
|
||||||
|
"conversion_rate_step" => "0.00",
|
||||||
|
"dropoff" => 0,
|
||||||
|
"dropoff_percentage" => "0.00",
|
||||||
|
"label" => "Visit /blog/announcement",
|
||||||
|
"visitors" => 2
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"conversion_rate" => "100.00",
|
||||||
|
"conversion_rate_step" => "100.00",
|
||||||
|
"dropoff" => 0,
|
||||||
|
"dropoff_percentage" => "0.00",
|
||||||
|
"label" => "Signup",
|
||||||
|
"visitors" => 2
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"conversion_rate" => "50.00",
|
||||||
|
"conversion_rate_step" => "50.00",
|
||||||
|
"dropoff" => 1,
|
||||||
|
"dropoff_percentage" => "50.00",
|
||||||
|
"label" => "Visit /cart/add/product",
|
||||||
|
"visitors" => 1
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"conversion_rate" => "50.00",
|
||||||
|
"conversion_rate_step" => "100.00",
|
||||||
|
"dropoff" => 0,
|
||||||
|
"dropoff_percentage" => "0.00",
|
||||||
|
"label" => "Purchase",
|
||||||
|
"visitors" => 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} = resp
|
||||||
|
end
|
||||||
|
|
||||||
|
test "404 for unknown funnel", %{site: site, conn: conn} do
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> get("/api/stats/#{site.domain}/funnels/122873/?period=day")
|
||||||
|
|> json_response(404)
|
||||||
|
|
||||||
|
assert resp == %{"error" => "Funnel not found"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "400 for bad funnel ID", %{site: site, conn: conn} do
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> get("/api/stats/#{site.domain}/funnels/foobar/?period=day")
|
||||||
|
|> json_response(400)
|
||||||
|
|
||||||
|
assert resp == %{"error" => "There was an error with your request"}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "computes all-time funnel with filters", %{conn: conn, user: user} do
|
||||||
|
site = insert(:site, stats_start_date: ~D[2020-01-01], members: [user])
|
||||||
|
{:ok, funnel} = setup_funnel(site, @build_funnel_with)
|
||||||
|
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, pathname: "/blog/announcement", user_id: @user_id),
|
||||||
|
build(:pageview,
|
||||||
|
pathname: "/blog/announcement",
|
||||||
|
user_id: @other_user_id,
|
||||||
|
utm_medium: "social",
|
||||||
|
timestamp: ~N[2021-01-01 12:00:00]
|
||||||
|
),
|
||||||
|
build(:event, name: "Signup", user_id: @user_id),
|
||||||
|
build(:event,
|
||||||
|
name: "Signup",
|
||||||
|
user_id: @other_user_id,
|
||||||
|
utm_medium: "social",
|
||||||
|
timestamp: ~N[2021-01-01 12:01:00]
|
||||||
|
),
|
||||||
|
build(:pageview, pathname: "/cart/add/product", user_id: @user_id),
|
||||||
|
build(:event, name: "Purchase", user_id: @user_id)
|
||||||
|
])
|
||||||
|
|
||||||
|
filters = Jason.encode!(%{utm_medium: "social"})
|
||||||
|
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> get("/api/stats/#{site.domain}/funnels/#{funnel.id}/?period=all&filters=#{filters}")
|
||||||
|
|> json_response(200)
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
"name" => "Test funnel",
|
||||||
|
"all_visitors" => 1,
|
||||||
|
"entering_visitors" => 1,
|
||||||
|
"entering_visitors_percentage" => "100.00",
|
||||||
|
"never_entering_visitors" => 0,
|
||||||
|
"never_entering_visitors_percentage" => "0.00",
|
||||||
|
"steps" => [
|
||||||
|
%{
|
||||||
|
"conversion_rate" => "100.00",
|
||||||
|
"conversion_rate_step" => "0.00",
|
||||||
|
"dropoff" => 0,
|
||||||
|
"dropoff_percentage" => "0.00",
|
||||||
|
"label" => "Visit /blog/announcement",
|
||||||
|
"visitors" => 1
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"conversion_rate" => "100.00",
|
||||||
|
"conversion_rate_step" => "100.00",
|
||||||
|
"dropoff" => 0,
|
||||||
|
"dropoff_percentage" => "0.00",
|
||||||
|
"label" => "Signup",
|
||||||
|
"visitors" => 1
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"conversion_rate" => "0.00",
|
||||||
|
"conversion_rate_step" => "0.00",
|
||||||
|
"dropoff" => 1,
|
||||||
|
"dropoff_percentage" => "100.00",
|
||||||
|
"label" => "Visit /cart/add/product",
|
||||||
|
"visitors" => 0
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"conversion_rate" => "0.00",
|
||||||
|
"conversion_rate_step" => "0.00",
|
||||||
|
"dropoff" => 0,
|
||||||
|
"dropoff_percentage" => "0.00",
|
||||||
|
"label" => "Purchase",
|
||||||
|
"visitors" => 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} = resp
|
||||||
|
end
|
||||||
|
|
||||||
|
test "computes an empty funnel", %{conn: conn, site: site} do
|
||||||
|
{:ok, funnel} = setup_funnel(site, @build_funnel_with)
|
||||||
|
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> get("/api/stats/#{site.domain}/funnels/#{funnel.id}/?period=day")
|
||||||
|
|> json_response(200)
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
"name" => "Test funnel",
|
||||||
|
"all_visitors" => 0,
|
||||||
|
"entering_visitors" => 0,
|
||||||
|
"entering_visitors_percentage" => "0.00",
|
||||||
|
"never_entering_visitors" => 0,
|
||||||
|
"never_entering_visitors_percentage" => "0.00",
|
||||||
|
"steps" => [
|
||||||
|
%{
|
||||||
|
"conversion_rate" => "0.00",
|
||||||
|
"conversion_rate_step" => "0.00",
|
||||||
|
"dropoff" => 0,
|
||||||
|
"dropoff_percentage" => "0.00",
|
||||||
|
"label" => "Visit /blog/announcement",
|
||||||
|
"visitors" => 0
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"conversion_rate" => "0.00",
|
||||||
|
"conversion_rate_step" => "0.00",
|
||||||
|
"dropoff" => 0,
|
||||||
|
"dropoff_percentage" => "0.00",
|
||||||
|
"label" => "Signup",
|
||||||
|
"visitors" => 0
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"conversion_rate" => "0.00",
|
||||||
|
"conversion_rate_step" => "0.00",
|
||||||
|
"dropoff" => 0,
|
||||||
|
"dropoff_percentage" => "0.00",
|
||||||
|
"label" => "Visit /cart/add/product",
|
||||||
|
"visitors" => 0
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"conversion_rate" => "0.00",
|
||||||
|
"conversion_rate_step" => "0.00",
|
||||||
|
"dropoff" => 0,
|
||||||
|
"dropoff_percentage" => "0.00",
|
||||||
|
"label" => "Purchase",
|
||||||
|
"visitors" => 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} = resp
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /api/stats/funnel - disallowed filters" do
|
||||||
|
setup [:create_user, :log_in, :create_new_site]
|
||||||
|
|
||||||
|
test "event:page", %{conn: conn, site: site} do
|
||||||
|
{:ok, funnel} = setup_funnel(site, @build_funnel_with)
|
||||||
|
|
||||||
|
filters = Jason.encode!(%{page: "/pageA"})
|
||||||
|
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> get("/api/stats/#{site.domain}/funnels/#{funnel.id}/?period=day&filters=#{filters}")
|
||||||
|
|> json_response(400)
|
||||||
|
|
||||||
|
assert resp == %{
|
||||||
|
"error" => "We are unable to show funnels when the dashboard is filtered by pages",
|
||||||
|
"level" => "normal"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "event:goal", %{conn: conn, site: site} do
|
||||||
|
{:ok, funnel} = setup_funnel(site, @build_funnel_with)
|
||||||
|
|
||||||
|
filters = Jason.encode!(%{goal: "Signup", page: "/pageA"})
|
||||||
|
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> get("/api/stats/#{site.domain}/funnels/#{funnel.id}/?period=day&filters=#{filters}")
|
||||||
|
|> json_response(400)
|
||||||
|
|
||||||
|
assert resp == %{
|
||||||
|
"error" => "We are unable to show funnels when the dashboard is filtered by goals",
|
||||||
|
"level" => "normal"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "period: realtime", %{conn: conn, site: site} do
|
||||||
|
{:ok, funnel} = setup_funnel(site, @build_funnel_with)
|
||||||
|
|
||||||
|
resp =
|
||||||
|
conn
|
||||||
|
|> get("/api/stats/#{site.domain}/funnels/#{funnel.id}/?period=realtime")
|
||||||
|
|> json_response(400)
|
||||||
|
|
||||||
|
assert resp == %{
|
||||||
|
"error" =>
|
||||||
|
"We are unable to show funnels when the dashboard is filtered by realtime period",
|
||||||
|
"level" => "normal"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp setup_goals(site, goals) when is_list(goals) do
|
||||||
|
goals =
|
||||||
|
Enum.map(goals, fn {type, value} ->
|
||||||
|
{:ok, g} = Plausible.Goals.create(site, %{type => value})
|
||||||
|
g
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, goals}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp setup_funnel(site, goal_names) do
|
||||||
|
{:ok, goals} = setup_goals(site, goal_names)
|
||||||
|
Plausible.Funnels.create(site, "Test funnel", Enum.map(goals, &%{"goal_id" => &1.id}))
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue
Block a user