mirror of
https://github.com/plausible/analytics.git
synced 2024-11-23 20:13:31 +03:00
Merge pull request #16 from plausible-insights/goals
Add goals and conversions
This commit is contained in:
commit
f36b7afbb8
@ -31,3 +31,22 @@ if (flash) {
|
|||||||
flash.style.display = 'none'
|
flash.style.display = 'none'
|
||||||
}, 2500)
|
}, 2500)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const registerForm = document.getElementById('register-form')
|
||||||
|
|
||||||
|
if (registerForm) {
|
||||||
|
registerForm.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setTimeout(submitForm, 1000);
|
||||||
|
var formSubmitted = false;
|
||||||
|
|
||||||
|
function submitForm() {
|
||||||
|
if (!formSubmitted) {
|
||||||
|
formSubmitted = true;
|
||||||
|
registerForm.submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plausible('trigger', 'Signup', {callback: submitForm});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ignore(reason) {
|
function ignore(reason) {
|
||||||
console.warn('[Plausible] Ignoring pageview because ' + reason);
|
console.warn('[Plausible] Ignoring event because ' + reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUrl() {
|
function getUrl() {
|
||||||
@ -77,7 +77,7 @@
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function trigger(eventName) {
|
function trigger(eventName, options) {
|
||||||
if (/localhost$/.test(window.location.hostname)) return ignore('website is running locally');
|
if (/localhost$/.test(window.location.hostname)) return ignore('website is running locally');
|
||||||
if (window.location.protocol === 'file:') return ignore('website is running locally');
|
if (window.location.protocol === 'file:') return ignore('website is running locally');
|
||||||
if (window.document.visibilityState === 'prerender') return ignore('document is prerendering');
|
if (window.document.visibilityState === 'prerender') return ignore('document is prerendering');
|
||||||
@ -95,13 +95,14 @@
|
|||||||
request.onreadystatechange = function() {
|
request.onreadystatechange = function() {
|
||||||
if (request.readyState == XMLHttpRequest.DONE) {
|
if (request.readyState == XMLHttpRequest.DONE) {
|
||||||
setUserData(payload)
|
setUserData(payload)
|
||||||
|
options && options.callback && options.callback()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function page() {
|
function page(options) {
|
||||||
trigger('pageview')
|
trigger('pageview', options)
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackPushState() {
|
function trackPushState() {
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ignore(reason) {
|
function ignore(reason) {
|
||||||
console.warn('[Plausible] Ignoring pageview because ' + reason);
|
console.warn('[Plausible] Ignoring event because ' + reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUrl() {
|
function getUrl() {
|
||||||
|
@ -124,6 +124,15 @@ if (domainEl) {
|
|||||||
document.getElementById('browsers-stats').innerHTML = res
|
document.getElementById('browsers-stats').innerHTML = res
|
||||||
router.updateLinkHandlers()
|
router.updateLinkHandlers()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (document.getElementById('conversion-stats')) {
|
||||||
|
fetch(`/stats/${domain}/conversions${location.search}`)
|
||||||
|
.then(res => res.text())
|
||||||
|
.then((res) => {
|
||||||
|
document.getElementById('conversion-stats').innerHTML = res
|
||||||
|
router.updateLinkHandlers()
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
setInterval(function() {
|
setInterval(function() {
|
||||||
|
34
lib/plausible/goal/schema.ex
Normal file
34
lib/plausible/goal/schema.ex
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
defmodule Plausible.Goal do
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
schema "goals" do
|
||||||
|
field :domain, :string
|
||||||
|
field :event_name, :string
|
||||||
|
field :page_path, :string
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
|
||||||
|
def changeset(goal, attrs \\ %{}) do
|
||||||
|
goal
|
||||||
|
|> cast(attrs, [:domain, :event_name, :page_path])
|
||||||
|
|> validate_required([:domain])
|
||||||
|
|> validate_event_name_and_page_path()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_event_name_and_page_path(changeset) do
|
||||||
|
if present?(changeset, :event_name) || present?(changeset, :page_path) do
|
||||||
|
changeset
|
||||||
|
else
|
||||||
|
changeset
|
||||||
|
|> add_error(:event_name, "this field is required")
|
||||||
|
|> add_error(:page_path, "this field is required")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp present?(changeset, field) do
|
||||||
|
value = get_field(changeset, field)
|
||||||
|
value && value != ""
|
||||||
|
end
|
||||||
|
end
|
21
lib/plausible/goals.ex
Normal file
21
lib/plausible/goals.ex
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
defmodule Plausible.Goals do
|
||||||
|
use Plausible.Repo
|
||||||
|
alias Plausible.Goal
|
||||||
|
|
||||||
|
def create(site, params) do
|
||||||
|
params = Map.merge(params, %{"domain" => site.domain})
|
||||||
|
|
||||||
|
Goal.changeset(%Goal{}, params) |> Repo.insert
|
||||||
|
end
|
||||||
|
|
||||||
|
def for_site(domain) do
|
||||||
|
Repo.all(
|
||||||
|
from g in Goal,
|
||||||
|
where: g.domain == ^domain
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete(id) do
|
||||||
|
Repo.one(from g in Goal, where: g.id == ^id) |> Repo.delete!
|
||||||
|
end
|
||||||
|
end
|
@ -18,6 +18,13 @@ defmodule Plausible.Sites do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def has_goals?(site) do
|
||||||
|
Repo.exists?(
|
||||||
|
from g in Plausible.Goal,
|
||||||
|
where: g.domain == ^site.domain
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def is_owner?(user_id, site) do
|
def is_owner?(user_id, site) do
|
||||||
Repo.exists?(
|
Repo.exists?(
|
||||||
from sm in Plausible.Site.Membership,
|
from sm in Plausible.Site.Membership,
|
||||||
|
@ -181,6 +181,66 @@ defmodule Plausible.Stats do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def goal_conversions(site, query, _limit \\ 5) do
|
||||||
|
goals = Repo.all(from g in Plausible.Goal, where: g.domain == ^site.domain)
|
||||||
|
fetch_pageview_goals(goals, site, query)
|
||||||
|
++ fetch_event_goals(goals, site, query)
|
||||||
|
|> sort_conversions()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_event_goals(goals, site, query) do
|
||||||
|
{:ok, first} = NaiveDateTime.new(query.date_range.first, ~T[00:00:00])
|
||||||
|
first_datetime = Timex.to_datetime(first, site.timezone)
|
||||||
|
|
||||||
|
{:ok, last} = NaiveDateTime.new(query.date_range.last |> Timex.shift(days: 1), ~T[00:00:00])
|
||||||
|
last_datetime = Timex.to_datetime(last, site.timezone)
|
||||||
|
|
||||||
|
events = Enum.map(goals, fn goal -> goal.event_name end)
|
||||||
|
|> Enum.filter(&(&1))
|
||||||
|
|
||||||
|
if Enum.count(events) > 0 do
|
||||||
|
Repo.all(
|
||||||
|
from e in Plausible.Event,
|
||||||
|
where: e.hostname == ^site.domain,
|
||||||
|
where: e.timestamp >= ^first_datetime and e.timestamp < ^last_datetime,
|
||||||
|
where: e.name in ^events,
|
||||||
|
group_by: e.name,
|
||||||
|
select: {e.name, count(e.user_id, :distinct)}
|
||||||
|
)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_pageview_goals(goals, site, query) do
|
||||||
|
{:ok, first} = NaiveDateTime.new(query.date_range.first, ~T[00:00:00])
|
||||||
|
first_datetime = Timex.to_datetime(first, site.timezone)
|
||||||
|
|
||||||
|
{:ok, last} = NaiveDateTime.new(query.date_range.last |> Timex.shift(days: 1), ~T[00:00:00])
|
||||||
|
last_datetime = Timex.to_datetime(last, site.timezone)
|
||||||
|
|
||||||
|
pages = Enum.map(goals, fn goal -> goal.page_path end)
|
||||||
|
|> Enum.filter(&(&1))
|
||||||
|
|
||||||
|
if Enum.count(pages) > 0 do
|
||||||
|
Repo.all(
|
||||||
|
from e in Plausible.Event,
|
||||||
|
where: e.hostname == ^site.domain,
|
||||||
|
where: e.timestamp >= ^first_datetime and e.timestamp < ^last_datetime,
|
||||||
|
where: e.name == "pageview",
|
||||||
|
where: e.pathname in ^pages,
|
||||||
|
group_by: e.pathname,
|
||||||
|
select: {fragment("concat('Visit ', ?)", e.pathname), count(e.user_id, :distinct)}
|
||||||
|
)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sort_conversions(conversions) do
|
||||||
|
Enum.sort_by(conversions, fn {_, count} -> -count end)
|
||||||
|
end
|
||||||
|
|
||||||
defp base_query(site, query) do
|
defp base_query(site, query) do
|
||||||
{:ok, first} = NaiveDateTime.new(query.date_range.first, ~T[00:00:00])
|
{:ok, first} = NaiveDateTime.new(query.date_range.first, ~T[00:00:00])
|
||||||
first_datetime = Timex.to_datetime(first, site.timezone)
|
first_datetime = Timex.to_datetime(first, site.timezone)
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
defmodule PlausibleWeb.SiteController do
|
defmodule PlausibleWeb.SiteController do
|
||||||
use PlausibleWeb, :controller
|
use PlausibleWeb, :controller
|
||||||
use Plausible.Repo
|
use Plausible.Repo
|
||||||
alias Plausible.Sites
|
alias Plausible.{Sites, Goals}
|
||||||
|
|
||||||
plug PlausibleWeb.RequireAccountPlug
|
plug PlausibleWeb.RequireAccountPlug
|
||||||
|
|
||||||
@ -33,6 +33,59 @@ defmodule PlausibleWeb.SiteController do
|
|||||||
|> render("snippet.html", site: site, layout: {PlausibleWeb.LayoutView, "focus.html"})
|
|> render("snippet.html", site: site, layout: {PlausibleWeb.LayoutView, "focus.html"})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def goals(conn, %{"website" => website}) do
|
||||||
|
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
|
||||||
|
goals = Goals.for_site(site.domain)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> assign(:skip_plausible_tracking, true)
|
||||||
|
|> render("goal_settings.html",
|
||||||
|
site: site,
|
||||||
|
goals: goals,
|
||||||
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def new_goal(conn, %{"website" => website}) do
|
||||||
|
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
|
||||||
|
changeset = Plausible.Goal.changeset(%Plausible.Goal{})
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> assign(:skip_plausible_tracking, true)
|
||||||
|
|> render("new_goal.html",
|
||||||
|
site: site,
|
||||||
|
changeset: changeset,
|
||||||
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_goal(conn, %{"website" => website, "goal" => goal}) do
|
||||||
|
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
|
||||||
|
|
||||||
|
case Plausible.Goals.create(site, goal) do
|
||||||
|
{:ok, _} ->
|
||||||
|
conn
|
||||||
|
|> put_flash(:success, "Goal created succesfully")
|
||||||
|
|> redirect(to: "/#{website}/goals")
|
||||||
|
{:error, :goal, changeset, _} ->
|
||||||
|
conn
|
||||||
|
|> assign(:skip_plausible_tracking, true)
|
||||||
|
|> render("new_goal.html",
|
||||||
|
site: site,
|
||||||
|
changeset: changeset,
|
||||||
|
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_goal(conn, %{"website" => website, "id" => goal_id}) do
|
||||||
|
Plausible.Goals.delete(goal_id)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_flash(:success, "Goal deleted succesfully")
|
||||||
|
|> redirect(to: "/#{website}/goals")
|
||||||
|
end
|
||||||
|
|
||||||
def settings(conn, %{"website" => website}) do
|
def settings(conn, %{"website" => website}) do
|
||||||
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
|
site = Sites.get_for_user!(conn.assigns[:current_user].id, website)
|
||||||
|> Repo.preload(:google_auth)
|
|> Repo.preload(:google_auth)
|
||||||
|
@ -25,12 +25,14 @@ defmodule PlausibleWeb.StatsController do
|
|||||||
{conn, params} = fetch_period(conn, site)
|
{conn, params} = fetch_period(conn, site)
|
||||||
query = Stats.Query.from(site.timezone, params)
|
query = Stats.Query.from(site.timezone, params)
|
||||||
current_visitors = Stats.current_visitors(site)
|
current_visitors = Stats.current_visitors(site)
|
||||||
|
has_goals = user && Plausible.Sites.has_goals?(site)
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> assign(:skip_plausible_tracking, !demo)
|
|> assign(:skip_plausible_tracking, !demo)
|
||||||
|> put_session(site.domain <> "_offer_email_report", nil)
|
|> put_session(site.domain <> "_offer_email_report", nil)
|
||||||
|> render("stats.html",
|
|> render("stats.html",
|
||||||
site: site,
|
site: site,
|
||||||
|
has_goals: has_goals,
|
||||||
query: query,
|
query: query,
|
||||||
current_visitors: current_visitors,
|
current_visitors: current_visitors,
|
||||||
title: "Plausible · " <> site.domain,
|
title: "Plausible · " <> site.domain,
|
||||||
@ -310,6 +312,20 @@ defmodule PlausibleWeb.StatsController do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def conversions_preview(conn, %{"domain" => domain}) do
|
||||||
|
site = Repo.get_by(Plausible.Site, domain: domain)
|
||||||
|
|
||||||
|
if site && current_user_can_access?(conn, site) do
|
||||||
|
{conn, params} = fetch_period(conn, site)
|
||||||
|
query = Stats.Query.from(site.timezone, params)
|
||||||
|
goals = Stats.goal_conversions(site, query)
|
||||||
|
|
||||||
|
render(conn, "conversions_preview.html", layout: false, query: query, site: site, goals: goals)
|
||||||
|
else
|
||||||
|
render_error(conn, 404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp current_user_can_access?(_conn, %Plausible.Site{public: true}) do
|
defp current_user_can_access?(_conn, %Plausible.Site{public: true}) do
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
@ -93,6 +93,10 @@ defmodule PlausibleWeb.Router do
|
|||||||
put "/sites/:website/monthly-report", SiteController, :update_monthly_settings
|
put "/sites/:website/monthly-report", SiteController, :update_monthly_settings
|
||||||
get "/:website/snippet", SiteController, :add_snippet
|
get "/:website/snippet", SiteController, :add_snippet
|
||||||
get "/:website/settings", SiteController, :settings
|
get "/:website/settings", SiteController, :settings
|
||||||
|
get "/:website/goals", SiteController, :goals
|
||||||
|
get "/:website/goals/new", SiteController, :new_goal
|
||||||
|
post "/:website/goals", SiteController, :create_goal
|
||||||
|
delete "/:website/goals/:id", SiteController, :delete_goal
|
||||||
put "/:website/settings", SiteController, :update_settings
|
put "/:website/settings", SiteController, :update_settings
|
||||||
put "/:website/settings/google", SiteController, :update_google_auth
|
put "/:website/settings/google", SiteController, :update_google_auth
|
||||||
delete "/:website", SiteController, :delete_site
|
delete "/:website", SiteController, :delete_site
|
||||||
@ -103,6 +107,7 @@ defmodule PlausibleWeb.Router do
|
|||||||
get "/stats/:domain/screen-sizes", StatsController, :screen_sizes_preview
|
get "/stats/:domain/screen-sizes", StatsController, :screen_sizes_preview
|
||||||
get "/stats/:domain/operating-systems", StatsController, :operating_systems_preview
|
get "/stats/:domain/operating-systems", StatsController, :operating_systems_preview
|
||||||
get "/stats/:domain/browsers", StatsController, :browsers_preview
|
get "/stats/:domain/browsers", StatsController, :browsers_preview
|
||||||
|
get "/stats/:domain/conversions", StatsController, :conversions_preview
|
||||||
get "/stats/:domain/main-graph", StatsController, :main_graph
|
get "/stats/:domain/main-graph", StatsController, :main_graph
|
||||||
get "/:website/*path", StatsController, :stats
|
get "/:website/*path", StatsController, :stats
|
||||||
end
|
end
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
<%= form_for @changeset, "/register", [class: "max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 py-6 mb-4 mt-8"], fn f -> %>
|
<%= form_for @changeset, "/register", [class: "max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 py-6 mb-4 mt-8", id: "register-form"], fn f -> %>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2>Enter your details to get started</h2>
|
<h2>Enter your details to get started</h2>
|
||||||
</div>
|
</div>
|
||||||
|
@ -23,5 +23,7 @@
|
|||||||
<p class="text-center text-grey text-xs mt-4">
|
<p class="text-center text-grey text-xs mt-4">
|
||||||
©2019 Plausible Insights. All rights reserved.
|
©2019 Plausible Insights. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
23
lib/plausible_web/templates/site/goal_settings.html.eex
Normal file
23
lib/plausible_web/templates/site/goal_settings.html.eex
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<%= if get_flash(@conn, :success) do %>
|
||||||
|
<div id="flash" class="max-w-sm w-full rounded mx-auto text-center bg-green-dark text-green-lightest text-sm font-bold px-4 w-full transition overflow-hidden" role="alert">
|
||||||
|
<p class="py-3"><%= get_flash(@conn, :success) %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8">
|
||||||
|
<h2>Goals for <%= @site.domain %></h2>
|
||||||
|
<div class="my-6">
|
||||||
|
<%= if Enum.count(@goals) > 0 do %>
|
||||||
|
<%= for goal <- @goals do %>
|
||||||
|
<div class="border-b border-grey-light py-3 flex justify-between">
|
||||||
|
<small class="font-bold"><%= goal_name(goal) %></small>
|
||||||
|
<%= button("❌", to: "/#{@site.domain}/goals/#{goal.id}", method: :delete, class: "text-sm", data: [confirm: "Are you sure you want to remove goal #{goal_name(goal)}? This will just affect the UI, all of your analytics data will stay intact."]) %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<div>No goals configured for this site yet</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= link("+ Add goal", to: "/#{@site.domain}/goals/new", class: "button mt-4 w-full") %>
|
||||||
|
</div>
|
37
lib/plausible_web/templates/site/new_goal.html.eex
Normal file
37
lib/plausible_web/templates/site/new_goal.html.eex
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<%= form_for @changeset, "/#{@site.domain}/goals", [class: "max-w-sm w-full mx-auto bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>
|
||||||
|
<h2>Add goal for <%= @site.domain %></h2>
|
||||||
|
<div class="mt-6 text-sm font-bold">Goal trigger</div>
|
||||||
|
<div class="my-3 w-full flex rounded border border-grey-light">
|
||||||
|
<div class="w-1/2 text-center py-2 border-r border-grey shadow-inner font-bold bg-grey-lighter cursor-pointer" id="pageview-tab">Pageview</div>
|
||||||
|
<div class="w-1/2 text-center py-2 bg-grey-lightest cursor-pointer" id="event-tab">Custom event</div>
|
||||||
|
</div>
|
||||||
|
<div class="my-6">
|
||||||
|
<div id="pageview-fields">
|
||||||
|
<%= label f, :page_path, class: "block text-sm font-bold" %>
|
||||||
|
<%= text_input f, :page_path, class: "transition mt-3 bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:bg-white focus:border-grey-light", placeholder: "/success" %>
|
||||||
|
<%= error_tag f, :page_path %>
|
||||||
|
</div>
|
||||||
|
<div id="event-fields" class="hidden">
|
||||||
|
<%= label f, :event_name, class: "block text-sm font-bold" %>
|
||||||
|
<%= text_input f, :event_name, class: "transition mt-3 bg-grey-lighter appearance-none border border-transparent rounded w-full p-2 text-grey-darker leading-normal appearance-none focus:outline-none focus:bg-white focus:border-grey-light", placeholder: "Signup" %>
|
||||||
|
<%= error_tag f, :event_name %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= submit "Add goal →", class: "button mt-4 w-full" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('pageview-tab').onclick = function() {
|
||||||
|
document.getElementById('pageview-fields').classList.remove('hidden')
|
||||||
|
document.getElementById('pageview-tab').classList.add('shadow-inner', 'font-bold')
|
||||||
|
document.getElementById('event-fields').classList.add('hidden')
|
||||||
|
document.getElementById('event-tab').classList.remove('shadow-inner', 'font-bold')
|
||||||
|
}
|
||||||
|
document.getElementById('event-tab').onclick = function() {
|
||||||
|
document.getElementById('event-fields').classList.remove('hidden')
|
||||||
|
document.getElementById('event-tab').classList.add('shadow-inner', 'font-bold')
|
||||||
|
document.getElementById('pageview-fields').classList.add('hidden')
|
||||||
|
document.getElementById('pageview-tab').classList.remove('shadow-inner', 'font-bold')
|
||||||
|
}
|
||||||
|
</script>
|
@ -0,0 +1,27 @@
|
|||||||
|
<div class="text-center">
|
||||||
|
<h2>Conversions</h2>
|
||||||
|
<div class="text-grey-darker mt-1">by unique visitors</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<%= if Enum.count(@goals) > 0 do %>
|
||||||
|
<%= for {key, count} <- @goals do %>
|
||||||
|
<div class="flex items-center justify-between my-2">
|
||||||
|
<span class="truncate" style="max-width: 80%;"><%= key %></span>
|
||||||
|
<span title="<%= count %>"><%= large_number_format(count) %></span>
|
||||||
|
</div>
|
||||||
|
<%= bar(count, @goals, :indigo) %>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<div class="text-center">No conversions for this period</div>
|
||||||
|
<div class="text-center mt-2"><%= link("Manage goals", to: "/#{@site.domain}/goals", class: "text-indigo") %></div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<%= if Enum.count(@goals) >= 5 do %>
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="/<%= @site.domain %>/conversions<%= query_params(@query) %>" data-pushstate class="font-bold text-sm text-grey-dark hover:text-red transition tracking-wide">
|
||||||
|
<svg style="fill: #8795a1;" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-maximize"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
|
||||||
|
MORE
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
@ -3,8 +3,7 @@
|
|||||||
<div class="text-center bg-blue-lighter text-blue-darkest text-sm font-bold px-4 w-full rounded transition" style="top: 91px" role="alert">
|
<div class="text-center bg-blue-lighter text-blue-darkest text-sm font-bold px-4 w-full rounded transition" style="top: 91px" role="alert">
|
||||||
<%= link("Click here to enable weekly email reports →", to: "/#{@site.domain}/settings#email-reports", class: "py-2 block") %>
|
<%= link("Click here to enable weekly email reports →", to: "/#{@site.domain}/settings#email-reports", class: "py-2 block") %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %> <div class="pt-12"></div>
|
||||||
<div class="pt-12"></div>
|
|
||||||
<div class="w-full sm:flex justify-between items-center">
|
<div class="w-full sm:flex justify-between items-center">
|
||||||
<div class="w-full flex items-center">
|
<div class="w-full flex items-center">
|
||||||
<h2 class="text-left mr-8">Analytics for <%= link(@site.domain, to: "//" <> @site.domain, target: "_blank") %></h2>
|
<h2 class="text-left mr-8">Analytics for <%= link(@site.domain, to: "//" <> @site.domain, target: "_blank") %></h2>
|
||||||
@ -91,14 +90,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full block md:flex items-start justify-between mt-6">
|
<div class="w-full block md:flex items-start justify-between mt-6">
|
||||||
<div class="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" id="screen-sizes-stats">
|
<div class="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" id="browsers-stats">
|
||||||
<div class="loading my-32 mx-auto"><div></div></div>
|
<div class="loading my-32 mx-auto"><div></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" id="operating-systems-stats">
|
<div class="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" id="operating-systems-stats">
|
||||||
<div class="loading my-32 mx-auto"><div></div></div>
|
<div class="loading my-32 mx-auto"><div></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" id="browsers-stats">
|
<div class="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" id="screen-sizes-stats">
|
||||||
<div class="loading my-32 mx-auto"><div></div></div>
|
<div class="loading my-32 mx-auto"><div></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%= if @has_goals do %>
|
||||||
|
<div class="w-full block md:flex items-start justify-between mt-6">
|
||||||
|
<div class="w-full md:w-31percent bg-white shadow-md rounded mt-4 p-4" id="conversion-stats">
|
||||||
|
<div class="loading my-32 mx-auto"><div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,14 @@
|
|||||||
defmodule PlausibleWeb.SiteView do
|
defmodule PlausibleWeb.SiteView do
|
||||||
use PlausibleWeb, :view
|
use PlausibleWeb, :view
|
||||||
|
|
||||||
|
def goal_name(%Plausible.Goal{page_path: page_path}) when is_binary(page_path) do
|
||||||
|
"Visit " <> page_path
|
||||||
|
end
|
||||||
|
|
||||||
|
def goal_name(%Plausible.Goal{event_name: name}) when is_binary(name) do
|
||||||
|
name
|
||||||
|
end
|
||||||
|
|
||||||
def snippet() do
|
def snippet() do
|
||||||
"""
|
"""
|
||||||
<script>
|
<script>
|
||||||
|
16
priv/repo/migrations/20191031051340_add_goals.exs
Normal file
16
priv/repo/migrations/20191031051340_add_goals.exs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
defmodule Plausible.Repo.Migrations.AddGoals do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:goals) do
|
||||||
|
add :domain, :text, null: false
|
||||||
|
add :name, :text, null: false
|
||||||
|
add :event_name, :text
|
||||||
|
add :page_path, :text
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:goals, [:domain, :name])
|
||||||
|
end
|
||||||
|
end
|
9
priv/repo/migrations/20191031063001_remove_goal_name.exs
Normal file
9
priv/repo/migrations/20191031063001_remove_goal_name.exs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
defmodule Plausible.Repo.Migrations.RemoveGoalName do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:goals) do
|
||||||
|
remove :name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -319,6 +319,61 @@ defmodule Plausible.StatsTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "goal_conversions" do
|
||||||
|
test "shows custom event conversions" do
|
||||||
|
site = insert(:site)
|
||||||
|
insert(:goal, %{domain: site.domain, event_name: "Register"})
|
||||||
|
insert(:goal, %{domain: site.domain, event_name: "Newsletter signup"})
|
||||||
|
insert(:event, name: "Register", hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 01:00:00])
|
||||||
|
insert(:event, name: "Register", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
|
||||||
|
insert(:event, name: "Newsletter signup", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
|
||||||
|
insert(:event, name: "Irrelevant", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
|
||||||
|
query = Stats.Query.from(site.timezone, %{"period" => "day", "date" => "2019-01-01"})
|
||||||
|
|
||||||
|
conversions = Stats.goal_conversions(site, query)
|
||||||
|
|
||||||
|
assert conversions == [
|
||||||
|
{"Register", 2},
|
||||||
|
{"Newsletter signup", 1}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows pageview conversions" do
|
||||||
|
site = insert(:site)
|
||||||
|
insert(:goal, %{domain: site.domain, page_path: "/success"})
|
||||||
|
insert(:goal, %{domain: site.domain, page_path: "/register"})
|
||||||
|
insert(:event, name: "pageview", pathname: "/success", hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 01:00:00])
|
||||||
|
insert(:event, name: "pageview", pathname: "/success", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
|
||||||
|
insert(:event, name: "pageview", pathname: "/register", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
|
||||||
|
insert(:event, name: "pageview", pathname: "/irrelevant", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
|
||||||
|
query = Stats.Query.from(site.timezone, %{"period" => "day", "date" => "2019-01-01"})
|
||||||
|
|
||||||
|
conversions = Stats.goal_conversions(site, query)
|
||||||
|
|
||||||
|
assert conversions == [
|
||||||
|
{"Visit /success", 2},
|
||||||
|
{"Visit /register", 1}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows mixed conversions in order of occurence" do
|
||||||
|
site = insert(:site)
|
||||||
|
insert(:goal, %{domain: site.domain, page_path: "/success"})
|
||||||
|
insert(:goal, %{domain: site.domain, event_name: "Signup"})
|
||||||
|
insert(:event, name: "Signup", hostname: site.domain, user_id: @user_id, timestamp: ~N[2019-01-01 01:00:00])
|
||||||
|
insert(:event, name: "pageview", pathname: "/success", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
|
||||||
|
insert(:event, name: "pageview", pathname: "/success", hostname: site.domain, user_id: UUID.uuid4(), timestamp: ~N[2019-01-01 01:00:00])
|
||||||
|
query = Stats.Query.from(site.timezone, %{"period" => "day", "date" => "2019-01-01"})
|
||||||
|
|
||||||
|
conversions = Stats.goal_conversions(site, query)
|
||||||
|
|
||||||
|
assert conversions == [
|
||||||
|
{"Visit /success", 2},
|
||||||
|
{"Signup", 1}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp months_ago(months) do
|
defp months_ago(months) do
|
||||||
Timex.now() |> Timex.shift(months: -months)
|
Timex.now() |> Timex.shift(months: -months)
|
||||||
end
|
end
|
||||||
|
@ -123,4 +123,74 @@ defmodule PlausibleWeb.SiteControllerTest do
|
|||||||
refute Repo.exists?(from e in Plausible.Event, where: e.id == ^pageview.id)
|
refute Repo.exists?(from e in Plausible.Event, where: e.id == ^pageview.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "GET /:website/goals/new" do
|
||||||
|
setup [:create_user, :log_in, :create_site]
|
||||||
|
|
||||||
|
test "shows form to create a new goal", %{conn: conn, site: site} do
|
||||||
|
conn = get(conn, "/#{site.domain}/goals/new")
|
||||||
|
|
||||||
|
assert html_response(conn, 200) =~ "Add goal"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "POST /:website/goals" do
|
||||||
|
setup [:create_user, :log_in, :create_site]
|
||||||
|
|
||||||
|
test "creates a pageview goal for the website", %{conn: conn, site: site} do
|
||||||
|
post(conn, "/#{site.domain}/goals", %{
|
||||||
|
goal: %{
|
||||||
|
page_path: "/success",
|
||||||
|
event_name: ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
goal = Repo.one(Plausible.Goal)
|
||||||
|
|
||||||
|
assert goal.page_path == "/success"
|
||||||
|
assert goal.event_name == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "creates a custom event goal for the website", %{conn: conn, site: site} do
|
||||||
|
post(conn, "/#{site.domain}/goals", %{
|
||||||
|
goal: %{
|
||||||
|
page_path: "",
|
||||||
|
event_name: "Signup"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
goal = Repo.one(Plausible.Goal)
|
||||||
|
|
||||||
|
assert goal.event_name == "Signup"
|
||||||
|
assert goal.page_path == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /:website/goals" do
|
||||||
|
setup [:create_user, :log_in, :create_site]
|
||||||
|
|
||||||
|
test "lists goals for the site", %{conn: conn, site: site} do
|
||||||
|
insert(:goal, domain: site.domain, event_name: "Custom event")
|
||||||
|
insert(:goal, domain: site.domain, page_path: "/register")
|
||||||
|
|
||||||
|
conn = get(conn, "/#{site.domain}/goals")
|
||||||
|
|
||||||
|
|
||||||
|
assert html_response(conn, 200) =~ "Goals for " <> site.domain
|
||||||
|
assert html_response(conn, 200) =~ "Custom event"
|
||||||
|
assert html_response(conn, 200) =~ "Visit /register"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "DELETE /:website/goals/:id" do
|
||||||
|
setup [:create_user, :log_in, :create_site]
|
||||||
|
|
||||||
|
test "lists goals for the site", %{conn: conn, site: site} do
|
||||||
|
goal = insert(:goal, domain: site.domain, event_name: "Custom event")
|
||||||
|
|
||||||
|
delete(conn, "/#{site.domain}/goals/#{goal.id}")
|
||||||
|
|
||||||
|
assert Repo.aggregate(Plausible.Goal, :count, :id) == 0
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
@ -42,6 +42,10 @@ defmodule Plausible.Factory do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def goal_factory do
|
||||||
|
%Plausible.Goal{}
|
||||||
|
end
|
||||||
|
|
||||||
def subscription_factory do
|
def subscription_factory do
|
||||||
%Plausible.Billing.Subscription{
|
%Plausible.Billing.Subscription{
|
||||||
paddle_subscription_id: sequence(:paddle_subscription_id, &"subscription-#{&1}"),
|
paddle_subscription_id: sequence(:paddle_subscription_id, &"subscription-#{&1}"),
|
||||||
|
Loading…
Reference in New Issue
Block a user