625 lines
18 KiB
Elixir
625 lines
18 KiB
Elixir
defmodule PlausibleWeb.Live.GoalSettings.Form do
|
|
@moduledoc """
|
|
Live view for the goal creation form
|
|
"""
|
|
use PlausibleWeb, :live_component
|
|
use Plausible
|
|
|
|
alias PlausibleWeb.Live.Components.ComboBox
|
|
alias Plausible.Repo
|
|
|
|
def update(assigns, socket) do
|
|
site = Repo.preload(assigns.site, [:team, :owners])
|
|
|
|
has_access_to_revenue_goals? =
|
|
Plausible.Billing.Feature.RevenueGoals.check_availability(site.team) == :ok
|
|
|
|
form =
|
|
(assigns.goal || %Plausible.Goal{})
|
|
|> Plausible.Goal.changeset()
|
|
|> to_form()
|
|
|
|
selected_tab =
|
|
case assigns.goal do
|
|
%{page_path: p, scroll_threshold: s} when not is_nil(p) and s > -1 -> "scroll"
|
|
%{page_path: p} when not is_nil(p) -> "pageviews"
|
|
_goal_or_nil -> "custom_events"
|
|
end
|
|
|
|
socket =
|
|
socket
|
|
|> assign(
|
|
id: assigns.id,
|
|
context_unique_id: assigns.context_unique_id,
|
|
form: form,
|
|
event_name_options_count: length(assigns.event_name_options),
|
|
event_name_options: Enum.map(assigns.event_name_options, &{&1, &1}),
|
|
current_user: assigns.current_user,
|
|
site_role: assigns.site_role,
|
|
site_team: assigns.site_team,
|
|
domain: assigns.domain,
|
|
selected_tab: selected_tab,
|
|
tab_sequence_id: 0,
|
|
site: site,
|
|
has_access_to_revenue_goals?: has_access_to_revenue_goals?,
|
|
existing_goals: assigns.existing_goals,
|
|
on_save_goal: assigns.on_save_goal,
|
|
on_autoconfigure: assigns.on_autoconfigure,
|
|
goal: assigns.goal
|
|
)
|
|
|
|
{:ok, socket}
|
|
end
|
|
|
|
# Regular functions instead of component calls are used here
|
|
# explicitly to avoid breaking change tracking. Done following
|
|
# advice from https://hexdocs.pm/phoenix_live_view/assigns-eex.html#the-assigns-variable.
|
|
def render(assigns) do
|
|
~H"""
|
|
<div id={@id}>
|
|
{if @goal, do: edit_form(assigns)}
|
|
{if is_nil(@goal), do: create_form(assigns)}
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
def edit_form(assigns) do
|
|
~H"""
|
|
<.form :let={f} for={@form} phx-submit="save-goal" phx-target={@myself}>
|
|
<.title>Edit goal for {@domain}</.title>
|
|
|
|
<.custom_event_fields
|
|
:if={@selected_tab == "custom_events"}
|
|
f={f}
|
|
suffix={@context_unique_id}
|
|
site_role={@site_role}
|
|
site_team={@site_team}
|
|
site={@site}
|
|
goal={@goal}
|
|
existing_goals={@existing_goals}
|
|
goal_options={@event_name_options}
|
|
has_access_to_revenue_goals?={@has_access_to_revenue_goals?}
|
|
/>
|
|
<.pageview_fields
|
|
:if={@selected_tab == "pageviews"}
|
|
f={f}
|
|
goal={@goal}
|
|
suffix={@context_unique_id}
|
|
site={@site}
|
|
/>
|
|
<.scroll_fields
|
|
:if={@selected_tab == "scroll"}
|
|
f={f}
|
|
goal={@goal}
|
|
suffix={@context_unique_id}
|
|
site={@site}
|
|
/>
|
|
|
|
<.button type="submit" class="w-full">
|
|
Update goal
|
|
</.button>
|
|
</.form>
|
|
"""
|
|
end
|
|
|
|
def create_form(assigns) do
|
|
~H"""
|
|
<.form
|
|
:let={f}
|
|
x-data="{ tabSelectionInProgress: false }"
|
|
for={@form}
|
|
phx-submit="save-goal"
|
|
phx-target={@myself}
|
|
>
|
|
<.title>Add goal for {@domain}</.title>
|
|
|
|
<.tabs current_user={@current_user} site={@site} selected_tab={@selected_tab} myself={@myself} />
|
|
|
|
<.custom_event_fields
|
|
:if={@selected_tab == "custom_events"}
|
|
x-show="!tabSelectionInProgress"
|
|
f={f}
|
|
suffix={suffix(@context_unique_id, @tab_sequence_id)}
|
|
site_role={@site_role}
|
|
site_team={@site_team}
|
|
site={@site}
|
|
existing_goals={@existing_goals}
|
|
goal_options={@event_name_options}
|
|
has_access_to_revenue_goals?={@has_access_to_revenue_goals?}
|
|
x-init="tabSelectionInProgress = false"
|
|
/>
|
|
<.pageview_fields
|
|
:if={@selected_tab == "pageviews"}
|
|
x-show="!tabSelectionInProgress"
|
|
f={f}
|
|
suffix={suffix(@context_unique_id, @tab_sequence_id)}
|
|
site={@site}
|
|
x-init="tabSelectionInProgress = false"
|
|
/>
|
|
<.scroll_fields
|
|
:if={@selected_tab == "scroll"}
|
|
x-show="!tabSelectionInProgress"
|
|
f={f}
|
|
suffix={suffix(@context_unique_id, @tab_sequence_id)}
|
|
site={@site}
|
|
x-init="tabSelectionInProgress = false"
|
|
/>
|
|
|
|
<div x-show="!tabSelectionInProgress">
|
|
<.button type="submit" class="w-full">
|
|
Add goal
|
|
</.button>
|
|
</div>
|
|
|
|
<button
|
|
:if={@selected_tab == "custom_events" && @event_name_options_count > 0}
|
|
x-show="!tabSelectionInProgress"
|
|
class="mt-4 text-sm hover:underline text-indigo-600 dark:text-indigo-400 text-left"
|
|
phx-click="autoconfigure"
|
|
phx-target={@myself}
|
|
>
|
|
<span :if={@event_name_options_count > 1}>
|
|
Already sending custom events? We've found {@event_name_options_count} custom events from the last 6 months that are not yet configured as goals. Click here to add them.
|
|
</span>
|
|
<span :if={@event_name_options_count == 1}>
|
|
Already sending custom events? We've found 1 custom event from the last 6 months that is not yet configured as a goal. Click here to add it.
|
|
</span>
|
|
</button>
|
|
</.form>
|
|
"""
|
|
end
|
|
|
|
attr(:f, Phoenix.HTML.Form)
|
|
attr(:site, Plausible.Site)
|
|
attr(:suffix, :string)
|
|
attr(:goal, Plausible.Goal, default: nil)
|
|
attr(:rest, :global)
|
|
|
|
def pageview_fields(assigns) do
|
|
~H"""
|
|
<div id="pageviews-form" class="py-2" {@rest}>
|
|
<div class="text-sm pb-6 text-gray-500 dark:text-gray-400 text-justify rounded-md">
|
|
Pageview goals allow you to measure how many people visit a specific page or section of your site. Learn more in <.styled_link
|
|
href="https://plausible.io/docs/pageview-goals"
|
|
new_tab={true}
|
|
>
|
|
our docs
|
|
</.styled_link>.
|
|
</div>
|
|
|
|
<.label for={"page_path_input_#{@suffix}"}>
|
|
Page path
|
|
</.label>
|
|
|
|
<.live_component
|
|
id={"page_path_input_#{@suffix}"}
|
|
submit_name="goal[page_path]"
|
|
class={[
|
|
"py-2"
|
|
]}
|
|
module={ComboBox}
|
|
suggest_fun={fn input, _options -> suggest_page_paths(input, @site) end}
|
|
selected={if @goal && @goal.page_path, do: @goal.page_path}
|
|
creatable
|
|
x-on-selection-change="document.getElementById('pageview_display_name_input').setAttribute('value', 'Visit ' + $event.detail.value.displayValue)"
|
|
/>
|
|
|
|
<.error :for={msg <- Enum.map(@f[:page_path].errors, &translate_error/1)}>
|
|
{msg}
|
|
</.error>
|
|
|
|
<.input
|
|
label="Display name"
|
|
id="pageview_display_name_input"
|
|
field={@f[:display_name]}
|
|
type="text"
|
|
x-data="{ firstFocus: true }"
|
|
x-on:focus="if (firstFocus) { $el.select(); firstFocus = false; }"
|
|
/>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
attr(:f, Phoenix.HTML.Form)
|
|
attr(:site, Plausible.Site)
|
|
attr(:suffix, :string)
|
|
attr(:goal, Plausible.Goal, default: nil)
|
|
attr(:rest, :global)
|
|
|
|
def scroll_fields(assigns) do
|
|
js =
|
|
if is_nil(assigns.goal) do
|
|
"""
|
|
{
|
|
scrollThreshold: '90',
|
|
pagePath: '',
|
|
displayName: '',
|
|
updateDisplayName() {
|
|
if (this.scrollThreshold && this.pagePath) {
|
|
this.displayName = `Scroll ${this.scrollThreshold}% on ${this.pagePath}`
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
else
|
|
"""
|
|
{
|
|
scrollThreshold: '#{assigns.goal.scroll_threshold}',
|
|
pagePath: '#{assigns.goal.page_path}',
|
|
displayName: '#{assigns.goal.display_name}',
|
|
updateDisplayName() {}
|
|
}
|
|
"""
|
|
end
|
|
|
|
assigns = assign(assigns, :js, js)
|
|
|
|
~H"""
|
|
<div id="scroll-form" class="py-2" x-data={@js} {@rest}>
|
|
<div class="text-sm pb-6 text-gray-500 dark:text-gray-400 text-justify rounded-md">
|
|
Scroll Depth goals allow you to see how many people scroll beyond your desired scroll depth percentage threshold. Learn more in <.styled_link
|
|
href="https://plausible.io/docs/scroll-depth"
|
|
new_tab={true}
|
|
>
|
|
our docs
|
|
</.styled_link>.
|
|
</div>
|
|
|
|
<.label for={"scroll_threshold_input_#{@suffix}"}>
|
|
Scroll percentage threshold (1-100)
|
|
</.label>
|
|
|
|
<.input
|
|
id={"scroll_threshold_input_#{@suffix}"}
|
|
required
|
|
field={@f[:scroll_threshold]}
|
|
type="number"
|
|
min="1"
|
|
max="100"
|
|
step="1"
|
|
x-model="scrollThreshold"
|
|
x-on:change="updateDisplayName"
|
|
/>
|
|
|
|
<.label for={"scroll_page_path_input_#{@suffix}"} class="mt-3">
|
|
Page path
|
|
</.label>
|
|
|
|
<.live_component
|
|
id={"scroll_page_path_input_#{@suffix}"}
|
|
submit_name="goal[page_path]"
|
|
class={[
|
|
"py-2"
|
|
]}
|
|
module={ComboBox}
|
|
suggest_fun={fn input, _options -> suggest_page_paths(input, @site) end}
|
|
selected={if @goal && @goal.page_path, do: @goal.page_path}
|
|
creatable
|
|
x-on-selection-change="pagePath = $event.detail.value.displayValue; updateDisplayName()"
|
|
/>
|
|
|
|
<.error :for={msg <- Enum.map(@f[:page_path].errors, &translate_error/1)}>
|
|
{msg}
|
|
</.error>
|
|
|
|
<.input
|
|
label="Display name"
|
|
id="scroll_display_name_input"
|
|
field={@f[:display_name]}
|
|
type="text"
|
|
x-model="displayName"
|
|
x-data="{ firstFocus: true }"
|
|
x-on:focus="if (firstFocus) { $el.select(); firstFocus = false; }"
|
|
/>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
attr(:f, Phoenix.HTML.Form)
|
|
attr(:site, Plausible.Site)
|
|
attr(:site_role, :atom)
|
|
attr(:site_team, Plausible.Teams.Team)
|
|
attr(:suffix, :string)
|
|
attr(:existing_goals, :list)
|
|
attr(:goal_options, :list)
|
|
attr(:goal, Plausible.Goal, default: nil)
|
|
attr(:has_access_to_revenue_goals?, :boolean)
|
|
|
|
attr(:rest, :global)
|
|
|
|
def custom_event_fields(assigns) do
|
|
~H"""
|
|
<div id="custom-events-form" class="py-2" {@rest}>
|
|
<div id="event-fields">
|
|
<div class="text-sm pb-6 text-gray-500 dark:text-gray-400 text-justify rounded-md">
|
|
Custom Events are not tracked by default - you have to configure them on your site to be sent to Plausible. See examples and learn more in <.styled_link
|
|
href="https://plausible.io/docs/custom-event-goals"
|
|
new_tab={true}
|
|
>
|
|
our docs
|
|
</.styled_link>.
|
|
</div>
|
|
|
|
<div>
|
|
<.label for={"event_name_input_#{@suffix}"}>
|
|
Event name
|
|
</.label>
|
|
|
|
<.live_component
|
|
id={"event_name_input_#{@suffix}"}
|
|
submit_name="goal[event_name]"
|
|
placeholder="e.g. Signup"
|
|
class={[
|
|
"py-2"
|
|
]}
|
|
module={ComboBox}
|
|
suggest_fun={fn input, _options -> suggest_event_names(input, @site, @existing_goals) end}
|
|
options={@goal_options}
|
|
selected={if @goal && @goal.event_name, do: @goal.event_name}
|
|
creatable
|
|
x-on-selection-change="document.getElementById('custom_event_display_name_input').setAttribute('value', $event.detail.value.displayValue)"
|
|
/>
|
|
|
|
<.error :for={msg <- Enum.map(@f[:event_name].errors, &translate_error/1)}>
|
|
{msg}
|
|
</.error>
|
|
</div>
|
|
|
|
<div class="mt-2">
|
|
<.input
|
|
label="Display name"
|
|
id="custom_event_display_name_input"
|
|
field={@f[:display_name]}
|
|
type="text"
|
|
x-data="{ firstFocus: true }"
|
|
x-on:focus="if (firstFocus) { $el.select(); firstFocus = false; }"
|
|
/>
|
|
</div>
|
|
|
|
<.revenue_goal_settings
|
|
:if={ee?()}
|
|
f={@f}
|
|
site={@site}
|
|
site_role={@site_role}
|
|
site_team={@site_team}
|
|
has_access_to_revenue_goals?={@has_access_to_revenue_goals?}
|
|
goal={@goal}
|
|
suffix={@suffix}
|
|
/>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
def revenue_goal_settings(%{goal: %{currency: nil}} = assigns) do
|
|
~H"""
|
|
<div class="h-2"></div>
|
|
"""
|
|
end
|
|
|
|
def revenue_goal_settings(assigns) do
|
|
js_data =
|
|
Jason.encode!(%{
|
|
active: !!assigns.f[:currency].value and assigns.f[:currency].value != "",
|
|
currency: assigns.f[:currency].value
|
|
})
|
|
|
|
assigns = assign(assigns, selected_currency: currency_option(assigns.goal), js_data: js_data)
|
|
|
|
~H"""
|
|
<div x-data={@js_data}>
|
|
<%= if is_nil(@goal) do %>
|
|
<div class="mt-6 mb-3">
|
|
<.revenue_toggle {assigns} />
|
|
</div>
|
|
<% else %>
|
|
<label
|
|
data-test="goal-currency-label"
|
|
class="mt-4 mb-2 text-sm block font-medium dark:text-gray-100"
|
|
>
|
|
Currency
|
|
</label>
|
|
<% end %>
|
|
<div class="mb-2" x-show="active" id={"revenue-input-#{@suffix}"}>
|
|
<.live_component
|
|
id={"currency_input_#{@suffix}"}
|
|
submit_name={@f[:currency].name}
|
|
module={ComboBox}
|
|
selected={@selected_currency}
|
|
suggest_fun={
|
|
on_ee do
|
|
fn
|
|
"", [] ->
|
|
Plausible.Goal.Revenue.currency_options()
|
|
|
|
input, options ->
|
|
ComboBox.StaticSearch.suggest(input, options, weight_threshold: 0.8)
|
|
end
|
|
end
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
def tabs(assigns) do
|
|
~H"""
|
|
<div class="text-sm mt-6 font-medium dark:text-gray-100">Goal trigger</div>
|
|
<div class="my-2 text-sm w-full flex gap-1 overflow-hidden">
|
|
<.tab
|
|
id="event-tab"
|
|
tab_value="custom_events"
|
|
selected?={@selected_tab == "custom_events"}
|
|
myself={@myself}
|
|
>
|
|
Custom event
|
|
</.tab>
|
|
<.tab
|
|
id="pageview-tab"
|
|
tab_value="pageviews"
|
|
selected?={@selected_tab == "pageviews"}
|
|
myself={@myself}
|
|
>
|
|
Pageview
|
|
</.tab>
|
|
<.tab
|
|
id="scroll-tab"
|
|
tab_value="scroll"
|
|
selected?={@selected_tab == "scroll"}
|
|
myself={@myself}
|
|
>
|
|
Scroll depth
|
|
</.tab>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
attr(:id, :string, required: true)
|
|
attr(:tab_value, :string, required: true)
|
|
attr(:selected?, :boolean, required: true)
|
|
attr(:myself, :any, required: true)
|
|
slot(:inner_block, required: true)
|
|
|
|
defp tab(assigns) do
|
|
~H"""
|
|
<a
|
|
class={[
|
|
"flex-1 text-center py-2.5 rounded-md font-medium hover:bg-gray-100 dark:hover:bg-gray-750 transition-colors duration-150",
|
|
"cursor-pointer",
|
|
@selected? && "bg-gray-150 dark:bg-gray-700 text-gray-800 dark:text-white",
|
|
!@selected? && "dark:text-gray-200 text-gray-600 hover:text-gray-800 dark:hover:text-white"
|
|
]}
|
|
id={@id}
|
|
x-on:click={!@selected? && "tabSelectionInProgress = true"}
|
|
phx-click="switch-tab"
|
|
phx-value-tab={@tab_value}
|
|
phx-target={@myself}
|
|
>
|
|
{render_slot(@inner_block)}
|
|
</a>
|
|
"""
|
|
end
|
|
|
|
def handle_event("switch-tab", %{"tab" => tab}, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(:selected_tab, tab)
|
|
|> update(:tab_sequence_id, &(&1 + 1))
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
def handle_event("save-goal", %{"goal" => goal_params}, %{assigns: %{goal: nil}} = socket) do
|
|
case Plausible.Goals.create(socket.assigns.site, goal_params) do
|
|
{:ok, goal} ->
|
|
socket =
|
|
goal
|
|
|> Map.put(:funnels, [])
|
|
|> socket.assigns.on_save_goal.(socket)
|
|
|
|
{:noreply, socket}
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
{:noreply, assign(socket, form: to_form(changeset))}
|
|
end
|
|
end
|
|
|
|
def handle_event(
|
|
"save-goal",
|
|
%{"goal" => goal_params},
|
|
%{assigns: %{goal: %Plausible.Goal{} = goal}} = socket
|
|
) do
|
|
case Plausible.Goals.update(goal, goal_params) do
|
|
{:ok, goal} ->
|
|
socket = socket.assigns.on_save_goal.(goal, socket)
|
|
|
|
{:noreply, socket}
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
{:noreply, assign(socket, form: to_form(changeset))}
|
|
end
|
|
end
|
|
|
|
def handle_event("autoconfigure", _params, socket) do
|
|
{:noreply, socket.assigns.on_autoconfigure.(socket)}
|
|
end
|
|
|
|
def suggest_page_paths(input, site) do
|
|
query =
|
|
Plausible.Stats.Query.build!(
|
|
site,
|
|
:internal,
|
|
%{
|
|
"site_id" => site.domain,
|
|
"date_range" => "all",
|
|
"metrics" => ["pageviews"],
|
|
"include" => %{"imports" => true}
|
|
}
|
|
)
|
|
|
|
site
|
|
|> Plausible.Stats.filter_suggestions(query, "page", input)
|
|
|> Enum.map(fn %{label: label, value: value} -> {label, value} end)
|
|
end
|
|
|
|
def suggest_event_names(input, site, existing_goals) do
|
|
existing_names =
|
|
existing_goals
|
|
|> Enum.reject(&is_nil(&1.event_name))
|
|
|> Enum.map(& &1.event_name)
|
|
|
|
site
|
|
|> Plausible.Stats.GoalSuggestions.suggest_event_names(input, exclude: existing_names)
|
|
|> Enum.map(fn name -> {name, name} end)
|
|
end
|
|
|
|
defp suffix(context_unique_id, tab_sequence_id) do
|
|
"#{context_unique_id}-tabseq#{tab_sequence_id}"
|
|
end
|
|
|
|
on_ee do
|
|
defp currency_option(nil), do: nil
|
|
|
|
defp currency_option(goal) do
|
|
Plausible.Goal.Revenue.revenue?(goal) &&
|
|
Plausible.Goal.Revenue.currency_option(goal.currency)
|
|
end
|
|
else
|
|
defp currency_option(_), do: nil
|
|
end
|
|
|
|
defp revenue_toggle(assigns) do
|
|
~H"""
|
|
<.tooltip enabled?={not @has_access_to_revenue_goals?}>
|
|
<:tooltip_content>
|
|
<div class="text-xs">
|
|
To get access to this feature
|
|
<PlausibleWeb.Components.Billing.upgrade_call_to_action
|
|
current_role={@site_role}
|
|
current_team={@site_team}
|
|
/>.
|
|
</div>
|
|
</:tooltip_content>
|
|
<div class="flex itemx-center mb-3">
|
|
<PlausibleWeb.Components.Generic.toggle_switch
|
|
id="enable-revenue-tracking"
|
|
id_suffix={@suffix}
|
|
js_active_var="active"
|
|
disabled={not @has_access_to_revenue_goals?}
|
|
/>
|
|
<span class={[
|
|
"ml-3 text-sm font-medium",
|
|
if(@has_access_to_revenue_goals?,
|
|
do: "text-gray-900 dark:text-gray-100",
|
|
else: "text-gray-500 dark:text-gray-400"
|
|
)
|
|
]}>
|
|
Enable revenue tracking
|
|
</span>
|
|
</div>
|
|
</.tooltip>
|
|
"""
|
|
end
|
|
end
|