mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 17:44:43 +03:00
Refactor: Split up the choose_plan LV code (#3637)
* move format_price to Plausible.Billing * move PlausibleWeb.Components.Billing file to subfolder * extract new Notice module * rename test file and module name * move growth_grandfathered notice to notice.ex * extract a PlanBenefits module * extract PlanBox component * extract PageviewSlider component * fix plan benefits text color
This commit is contained in:
parent
77ca5abbc8
commit
22ecbe7bc7
@ -244,6 +244,11 @@ defmodule Plausible.Billing do
|
||||
end
|
||||
end
|
||||
|
||||
@spec format_price(Money.t()) :: String.t()
|
||||
def format_price(money) do
|
||||
Money.to_string!(money, fractional_digits: 2, no_fraction_if_integer: true)
|
||||
end
|
||||
|
||||
def paddle_api(), do: Application.fetch_env!(:plausible, :paddle_api)
|
||||
|
||||
def cancelled_subscription_notice_dismiss_id(%Plausible.Auth.User{} = user) do
|
||||
|
@ -4,86 +4,8 @@ defmodule PlausibleWeb.Components.Billing do
|
||||
use Phoenix.Component
|
||||
import PlausibleWeb.Components.Generic
|
||||
require Plausible.Billing.Subscription.Status
|
||||
alias Plausible.Auth.User
|
||||
alias PlausibleWeb.Router.Helpers, as: Routes
|
||||
alias Plausible.Billing.{Subscription, Plans, Subscriptions}
|
||||
|
||||
attr(:billable_user, User, required: true)
|
||||
attr(:current_user, User, required: true)
|
||||
attr(:feature_mod, :atom, required: true, values: Plausible.Billing.Feature.list())
|
||||
attr(:grandfathered?, :boolean, default: false)
|
||||
attr(:size, :atom, default: :sm)
|
||||
attr(:rest, :global)
|
||||
|
||||
def premium_feature_notice(assigns) do
|
||||
~H"""
|
||||
<.notice
|
||||
:if={@feature_mod.check_availability(@billable_user) !== :ok}
|
||||
class="rounded-t-md rounded-b-none"
|
||||
size={@size}
|
||||
title="Notice"
|
||||
{@rest}
|
||||
>
|
||||
<%= account_label(@current_user, @billable_user) %> does not have access to <%= @feature_mod.display_name() %>. To get access to this feature,
|
||||
<.upgrade_call_to_action current_user={@current_user} billable_user={@billable_user} />.
|
||||
</.notice>
|
||||
"""
|
||||
end
|
||||
|
||||
attr(:billable_user, User, required: true)
|
||||
attr(:current_user, User, required: true)
|
||||
attr(:limit, :integer, required: true)
|
||||
attr(:resource, :string, required: true)
|
||||
attr(:rest, :global)
|
||||
|
||||
def limit_exceeded_notice(assigns) do
|
||||
~H"""
|
||||
<.notice {@rest} title="Notice">
|
||||
<%= account_label(@current_user, @billable_user) %> is limited to <%= @limit %> <%= @resource %>. To increase this limit,
|
||||
<.upgrade_call_to_action current_user={@current_user} billable_user={@billable_user} />.
|
||||
</.notice>
|
||||
"""
|
||||
end
|
||||
|
||||
attr(:current_user, :map)
|
||||
attr(:billable_user, :map)
|
||||
|
||||
defp upgrade_call_to_action(assigns) do
|
||||
billable_user = Plausible.Users.with_subscription(assigns.billable_user)
|
||||
|
||||
plan =
|
||||
Plausible.Billing.Plans.get_regular_plan(billable_user.subscription, only_non_expired: true)
|
||||
|
||||
trial? = Plausible.Billing.on_trial?(assigns.billable_user)
|
||||
growth? = plan && plan.kind == :growth
|
||||
|
||||
cond do
|
||||
assigns.billable_user.id !== assigns.current_user.id ->
|
||||
~H"please reach out to the site owner to upgrade their subscription"
|
||||
|
||||
growth? || trial? ->
|
||||
~H"""
|
||||
please
|
||||
<.link
|
||||
class="underline inline-block"
|
||||
href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)}
|
||||
>
|
||||
upgrade your subscription
|
||||
</.link>
|
||||
"""
|
||||
|
||||
true ->
|
||||
~H"please contact hello@plausible.io to upgrade your subscription"
|
||||
end
|
||||
end
|
||||
|
||||
defp account_label(current_user, billable_user) do
|
||||
if current_user.id == billable_user.id do
|
||||
"Your account"
|
||||
else
|
||||
"The owner of this site"
|
||||
end
|
||||
end
|
||||
alias Plausible.Billing.{Subscription, Subscriptions}
|
||||
|
||||
def render_monthly_pageview_usage(%{usage: usage} = assigns)
|
||||
when is_map_key(usage, :last_30_days) do
|
||||
@ -283,142 +205,6 @@ defmodule PlausibleWeb.Components.Billing do
|
||||
"""
|
||||
end
|
||||
|
||||
attr(:user, :map, required: true)
|
||||
attr(:dismissable, :boolean, default: true)
|
||||
|
||||
@doc """
|
||||
Given a user with a cancelled subscription, this component renders a cancelled
|
||||
subscription notice. If the given user does not have a subscription or it has a
|
||||
different status, this function returns an empty template.
|
||||
|
||||
It also takes a dismissable argument which renders the notice dismissable (with
|
||||
the help of JavaScript and localStorage). We show a dismissable notice about a
|
||||
cancelled subscription across the app, but when the user dismisses it, we will
|
||||
start displaying it in the account settings > subscription section instead.
|
||||
|
||||
So it's either shown across the app, or only on the /settings page. Depending
|
||||
on whether the localStorage flag to dismiss it has been set or not.
|
||||
"""
|
||||
def subscription_cancelled_notice(assigns)
|
||||
|
||||
def subscription_cancelled_notice(
|
||||
%{
|
||||
dismissable: true,
|
||||
user: %User{subscription: %Subscription{status: Subscription.Status.deleted()}}
|
||||
} = assigns
|
||||
) do
|
||||
~H"""
|
||||
<aside id="global-subscription-cancelled-notice" class="container">
|
||||
<PlausibleWeb.Components.Generic.notice
|
||||
dismissable_id={Plausible.Billing.cancelled_subscription_notice_dismiss_id(@user)}
|
||||
title="Subscription cancelled"
|
||||
theme={:red}
|
||||
class="shadow-md dark:shadow-none"
|
||||
>
|
||||
<.subscription_cancelled_notice_body user={@user} />
|
||||
</PlausibleWeb.Components.Generic.notice>
|
||||
</aside>
|
||||
"""
|
||||
end
|
||||
|
||||
def subscription_cancelled_notice(
|
||||
%{
|
||||
dismissable: false,
|
||||
user: %User{subscription: %Subscription{status: Subscription.Status.deleted()}}
|
||||
} = assigns
|
||||
) do
|
||||
assigns = assign(assigns, :container_id, "local-subscription-cancelled-notice")
|
||||
|
||||
~H"""
|
||||
<aside id={@container_id} class="hidden">
|
||||
<PlausibleWeb.Components.Generic.notice
|
||||
title="Subscription cancelled"
|
||||
theme={:red}
|
||||
class="shadow-md dark:shadow-none"
|
||||
>
|
||||
<.subscription_cancelled_notice_body user={@user} />
|
||||
</PlausibleWeb.Components.Generic.notice>
|
||||
</aside>
|
||||
<script
|
||||
data-localstorage-key={"notice_dismissed__#{Plausible.Billing.cancelled_subscription_notice_dismiss_id(assigns.user)}"}
|
||||
data-container-id={@container_id}
|
||||
>
|
||||
const dataset = document.currentScript.dataset
|
||||
|
||||
if (localStorage[dataset.localstorageKey]) {
|
||||
document.getElementById(dataset.containerId).classList.remove('hidden')
|
||||
}
|
||||
</script>
|
||||
"""
|
||||
end
|
||||
|
||||
def subscription_cancelled_notice(assigns), do: ~H""
|
||||
|
||||
attr(:class, :string, default: "")
|
||||
attr(:subscription, :any, default: nil)
|
||||
|
||||
def subscription_past_due_notice(
|
||||
%{subscription: %Subscription{status: Subscription.Status.past_due()}} = assigns
|
||||
) do
|
||||
~H"""
|
||||
<aside class={@class}>
|
||||
<PlausibleWeb.Components.Generic.notice
|
||||
title="Payment failed"
|
||||
class="shadow-md dark:shadow-none"
|
||||
>
|
||||
There was a problem with your latest payment. Please update your payment information to keep using Plausible.<.link
|
||||
href={@subscription.update_url}
|
||||
class="whitespace-nowrap font-semibold"
|
||||
> Update billing info <span aria-hidden="true"> →</span></.link>
|
||||
</PlausibleWeb.Components.Generic.notice>
|
||||
</aside>
|
||||
"""
|
||||
end
|
||||
|
||||
def subscription_past_due_notice(assigns), do: ~H""
|
||||
|
||||
attr(:class, :string, default: "")
|
||||
attr(:subscription, :any, default: nil)
|
||||
|
||||
def subscription_paused_notice(
|
||||
%{subscription: %Subscription{status: Subscription.Status.paused()}} = assigns
|
||||
) do
|
||||
~H"""
|
||||
<aside class={@class}>
|
||||
<PlausibleWeb.Components.Generic.notice
|
||||
title="Subscription paused"
|
||||
theme={:red}
|
||||
class="shadow-md dark:shadow-none"
|
||||
>
|
||||
Your subscription is paused due to failed payments. Please provide valid payment details to keep using Plausible.<.link
|
||||
href={@subscription.update_url}
|
||||
class="whitespace-nowrap font-semibold"
|
||||
> Update billing info <span aria-hidden="true"> →</span></.link>
|
||||
</PlausibleWeb.Components.Generic.notice>
|
||||
</aside>
|
||||
"""
|
||||
end
|
||||
|
||||
def subscription_paused_notice(assigns), do: ~H""
|
||||
|
||||
def upgrade_ineligible_notice(assigns) do
|
||||
~H"""
|
||||
<aside id="upgrade-eligible-notice" class="pb-6">
|
||||
<PlausibleWeb.Components.Generic.notice
|
||||
title="No sites owned"
|
||||
theme={:yellow}
|
||||
class="shadow-md dark:shadow-none"
|
||||
>
|
||||
You cannot start a subscription as your account doesn't own any sites. The account that owns the sites is responsible for the billing. Please either
|
||||
<.styled_link href="https://plausible.io/docs/transfer-ownership">
|
||||
transfer the sites
|
||||
</.styled_link>
|
||||
to your account or start a subscription from the account that owns your sites.
|
||||
</PlausibleWeb.Components.Generic.notice>
|
||||
</aside>
|
||||
"""
|
||||
end
|
||||
|
||||
def present_enterprise_plan(assigns) do
|
||||
~H"""
|
||||
<ul class="w-full py-4">
|
||||
@ -441,11 +227,6 @@ defmodule PlausibleWeb.Components.Billing do
|
||||
|> PlausibleWeb.StatsView.large_number_format()
|
||||
end
|
||||
|
||||
@spec format_price(Money.t()) :: String.t()
|
||||
def format_price(money) do
|
||||
Money.to_string!(money, fractional_digits: 2, no_fraction_if_integer: true)
|
||||
end
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :paddle_product_id, :string, required: true
|
||||
attr :checkout_disabled, :boolean, default: false
|
||||
@ -498,52 +279,6 @@ defmodule PlausibleWeb.Components.Billing do
|
||||
"""
|
||||
end
|
||||
|
||||
defp subscription_cancelled_notice_body(assigns) do
|
||||
if Plausible.Billing.Subscriptions.expired?(assigns.user.subscription) do
|
||||
~H"""
|
||||
<.link
|
||||
class="underline inline-block"
|
||||
href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)}
|
||||
>
|
||||
Upgrade your subscription
|
||||
</.link>
|
||||
to get access to your stats again.
|
||||
"""
|
||||
else
|
||||
~H"""
|
||||
<p>
|
||||
You have access to your stats until <span class="font-semibold inline"><%= Timex.format!(@user.subscription.next_bill_date, "{Mshort} {D}, {YYYY}") %></span>.
|
||||
<.link
|
||||
class="underline inline-block"
|
||||
href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)}
|
||||
>
|
||||
Upgrade your subscription
|
||||
</.link>
|
||||
to make sure you don't lose access.
|
||||
</p>
|
||||
<.lose_grandfathering_warning user={@user} />
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
defp lose_grandfathering_warning(%{user: %{subscription: subscription}} = assigns) do
|
||||
plan = Plans.get_regular_plan(subscription, only_non_expired: true)
|
||||
loses_grandfathering = plan && plan.generation < 4
|
||||
|
||||
assigns = assign(assigns, :loses_grandfathering, loses_grandfathering)
|
||||
|
||||
~H"""
|
||||
<p :if={@loses_grandfathering} class="mt-2">
|
||||
Please also note that by letting your subscription expire, you lose access to our grandfathered terms. If you want to subscribe again after that, your account will be offered the <.link
|
||||
href="https://plausible.io/#pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline"
|
||||
>latest pricing</.link>.
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
defp change_plan_or_upgrade_text(nil), do: "Upgrade"
|
||||
|
||||
defp change_plan_or_upgrade_text(%Subscription{status: Subscription.Status.deleted()}),
|
262
lib/plausible_web/components/billing/notice.ex
Normal file
262
lib/plausible_web/components/billing/notice.ex
Normal file
@ -0,0 +1,262 @@
|
||||
defmodule PlausibleWeb.Components.Billing.Notice do
|
||||
@moduledoc false
|
||||
|
||||
use Phoenix.Component
|
||||
require Plausible.Billing.Subscription.Status
|
||||
import PlausibleWeb.Components.Generic
|
||||
alias PlausibleWeb.Router.Helpers, as: Routes
|
||||
alias Plausible.Auth.User
|
||||
alias Plausible.Billing.{Subscription, Plans, Subscriptions, Feature}
|
||||
|
||||
attr(:billable_user, User, required: true)
|
||||
attr(:current_user, User, required: true)
|
||||
attr(:feature_mod, :atom, required: true, values: Feature.list())
|
||||
attr(:grandfathered?, :boolean, default: false)
|
||||
attr(:size, :atom, default: :sm)
|
||||
attr(:rest, :global)
|
||||
|
||||
def premium_feature(assigns) do
|
||||
~H"""
|
||||
<.notice
|
||||
:if={@feature_mod.check_availability(@billable_user) !== :ok}
|
||||
class="rounded-t-md rounded-b-none"
|
||||
size={@size}
|
||||
title="Notice"
|
||||
{@rest}
|
||||
>
|
||||
<%= account_label(@current_user, @billable_user) %> does not have access to <%= @feature_mod.display_name() %>. To get access to this feature,
|
||||
<.upgrade_call_to_action current_user={@current_user} billable_user={@billable_user} />.
|
||||
</.notice>
|
||||
"""
|
||||
end
|
||||
|
||||
attr(:billable_user, User, required: true)
|
||||
attr(:current_user, User, required: true)
|
||||
attr(:limit, :integer, required: true)
|
||||
attr(:resource, :string, required: true)
|
||||
attr(:rest, :global)
|
||||
|
||||
def limit_exceeded(assigns) do
|
||||
~H"""
|
||||
<.notice {@rest} title="Notice">
|
||||
<%= account_label(@current_user, @billable_user) %> is limited to <%= @limit %> <%= @resource %>. To increase this limit,
|
||||
<.upgrade_call_to_action current_user={@current_user} billable_user={@billable_user} />.
|
||||
</.notice>
|
||||
"""
|
||||
end
|
||||
|
||||
attr(:user, :map, required: true)
|
||||
attr(:dismissable, :boolean, default: true)
|
||||
|
||||
@doc """
|
||||
Given a user with a cancelled subscription, this component renders a cancelled
|
||||
subscription notice. If the given user does not have a subscription or it has a
|
||||
different status, this function returns an empty template.
|
||||
|
||||
It also takes a dismissable argument which renders the notice dismissable (with
|
||||
the help of JavaScript and localStorage). We show a dismissable notice about a
|
||||
cancelled subscription across the app, but when the user dismisses it, we will
|
||||
start displaying it in the account settings > subscription section instead.
|
||||
|
||||
So it's either shown across the app, or only on the /settings page. Depending
|
||||
on whether the localStorage flag to dismiss it has been set or not.
|
||||
"""
|
||||
def subscription_cancelled(assigns)
|
||||
|
||||
def subscription_cancelled(
|
||||
%{
|
||||
dismissable: true,
|
||||
user: %User{subscription: %Subscription{status: Subscription.Status.deleted()}}
|
||||
} = assigns
|
||||
) do
|
||||
~H"""
|
||||
<aside id="global-subscription-cancelled-notice" class="container">
|
||||
<.notice
|
||||
dismissable_id={Plausible.Billing.cancelled_subscription_notice_dismiss_id(@user)}
|
||||
title="Subscription cancelled"
|
||||
theme={:red}
|
||||
class="shadow-md dark:shadow-none"
|
||||
>
|
||||
<.subscription_cancelled_notice_body user={@user} />
|
||||
</.notice>
|
||||
</aside>
|
||||
"""
|
||||
end
|
||||
|
||||
def subscription_cancelled(
|
||||
%{
|
||||
dismissable: false,
|
||||
user: %User{subscription: %Subscription{status: Subscription.Status.deleted()}}
|
||||
} = assigns
|
||||
) do
|
||||
assigns = assign(assigns, :container_id, "local-subscription-cancelled-notice")
|
||||
|
||||
~H"""
|
||||
<aside id={@container_id} class="hidden">
|
||||
<.notice title="Subscription cancelled" theme={:red} class="shadow-md dark:shadow-none">
|
||||
<.subscription_cancelled_notice_body user={@user} />
|
||||
</.notice>
|
||||
</aside>
|
||||
<script
|
||||
data-localstorage-key={"notice_dismissed__#{Plausible.Billing.cancelled_subscription_notice_dismiss_id(assigns.user)}"}
|
||||
data-container-id={@container_id}
|
||||
>
|
||||
const dataset = document.currentScript.dataset
|
||||
|
||||
if (localStorage[dataset.localstorageKey]) {
|
||||
document.getElementById(dataset.containerId).classList.remove('hidden')
|
||||
}
|
||||
</script>
|
||||
"""
|
||||
end
|
||||
|
||||
def subscription_cancelled(assigns), do: ~H""
|
||||
|
||||
attr(:class, :string, default: "")
|
||||
attr(:subscription, :any, default: nil)
|
||||
|
||||
def subscription_past_due(
|
||||
%{subscription: %Subscription{status: Subscription.Status.past_due()}} = assigns
|
||||
) do
|
||||
~H"""
|
||||
<aside class={@class}>
|
||||
<.notice title="Payment failed" class="shadow-md dark:shadow-none">
|
||||
There was a problem with your latest payment. Please update your payment information to keep using Plausible.<.link
|
||||
href={@subscription.update_url}
|
||||
class="whitespace-nowrap font-semibold"
|
||||
> Update billing info <span aria-hidden="true"> →</span></.link>
|
||||
</.notice>
|
||||
</aside>
|
||||
"""
|
||||
end
|
||||
|
||||
def subscription_past_due(assigns), do: ~H""
|
||||
|
||||
attr(:class, :string, default: "")
|
||||
attr(:subscription, :any, default: nil)
|
||||
|
||||
def subscription_paused(
|
||||
%{subscription: %Subscription{status: Subscription.Status.paused()}} = assigns
|
||||
) do
|
||||
~H"""
|
||||
<aside class={@class}>
|
||||
<.notice title="Subscription paused" theme={:red} class="shadow-md dark:shadow-none">
|
||||
Your subscription is paused due to failed payments. Please provide valid payment details to keep using Plausible.<.link
|
||||
href={@subscription.update_url}
|
||||
class="whitespace-nowrap font-semibold"
|
||||
> Update billing info <span aria-hidden="true"> →</span></.link>
|
||||
</.notice>
|
||||
</aside>
|
||||
"""
|
||||
end
|
||||
|
||||
def subscription_paused(assigns), do: ~H""
|
||||
|
||||
def upgrade_ineligible(assigns) do
|
||||
~H"""
|
||||
<aside id="upgrade-eligible-notice" class="pb-6">
|
||||
<.notice title="No sites owned" theme={:yellow} class="shadow-md dark:shadow-none">
|
||||
You cannot start a subscription as your account doesn't own any sites. The account that owns the sites is responsible for the billing. Please either
|
||||
<.styled_link href="https://plausible.io/docs/transfer-ownership">
|
||||
transfer the sites
|
||||
</.styled_link>
|
||||
to your account or start a subscription from the account that owns your sites.
|
||||
</.notice>
|
||||
</aside>
|
||||
"""
|
||||
end
|
||||
|
||||
def growth_grandfathered(assigns) do
|
||||
~H"""
|
||||
<div class="mt-8 space-y-3 text-sm leading-6 text-gray-600 text-justify dark:text-gray-100 xl:mt-10">
|
||||
Your subscription has been grandfathered in at the same rate and terms as when you first joined. If you don't need the "Business" features, you're welcome to stay on this plan. You can adjust the pageview limit or change the billing frequency of this grandfathered plan. If you're interested in business features, you can upgrade to the new "Business" plan.
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp subscription_cancelled_notice_body(assigns) do
|
||||
if Subscriptions.expired?(assigns.user.subscription) do
|
||||
~H"""
|
||||
<.link
|
||||
class="underline inline-block"
|
||||
href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)}
|
||||
>
|
||||
Upgrade your subscription
|
||||
</.link>
|
||||
to get access to your stats again.
|
||||
"""
|
||||
else
|
||||
~H"""
|
||||
<p>
|
||||
You have access to your stats until <span class="font-semibold inline"><%= Timex.format!(@user.subscription.next_bill_date, "{Mshort} {D}, {YYYY}") %></span>.
|
||||
<.link
|
||||
class="underline inline-block"
|
||||
href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)}
|
||||
>
|
||||
Upgrade your subscription
|
||||
</.link>
|
||||
to make sure you don't lose access.
|
||||
</p>
|
||||
<.lose_grandfathering_warning user={@user} />
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
defp lose_grandfathering_warning(%{user: %{subscription: subscription}} = assigns) do
|
||||
plan = Plans.get_regular_plan(subscription, only_non_expired: true)
|
||||
loses_grandfathering = plan && plan.generation < 4
|
||||
|
||||
assigns = assign(assigns, :loses_grandfathering, loses_grandfathering)
|
||||
|
||||
~H"""
|
||||
<p :if={@loses_grandfathering} class="mt-2">
|
||||
Please also note that by letting your subscription expire, you lose access to our grandfathered terms. If you want to subscribe again after that, your account will be offered the <.link
|
||||
href="https://plausible.io/#pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline"
|
||||
>latest pricing</.link>.
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
attr(:current_user, :map)
|
||||
attr(:billable_user, :map)
|
||||
|
||||
defp upgrade_call_to_action(assigns) do
|
||||
billable_user = Plausible.Users.with_subscription(assigns.billable_user)
|
||||
|
||||
plan =
|
||||
Plans.get_regular_plan(billable_user.subscription, only_non_expired: true)
|
||||
|
||||
trial? = Plausible.Billing.on_trial?(assigns.billable_user)
|
||||
growth? = plan && plan.kind == :growth
|
||||
|
||||
cond do
|
||||
assigns.billable_user.id !== assigns.current_user.id ->
|
||||
~H"please reach out to the site owner to upgrade their subscription"
|
||||
|
||||
growth? || trial? ->
|
||||
~H"""
|
||||
please
|
||||
<.link
|
||||
class="underline inline-block"
|
||||
href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)}
|
||||
>
|
||||
upgrade your subscription
|
||||
</.link>
|
||||
"""
|
||||
|
||||
true ->
|
||||
~H"please contact hello@plausible.io to upgrade your subscription"
|
||||
end
|
||||
end
|
||||
|
||||
defp account_label(current_user, billable_user) do
|
||||
if current_user.id == billable_user.id do
|
||||
"Your account"
|
||||
else
|
||||
"The owner of this site"
|
||||
end
|
||||
end
|
||||
end
|
160
lib/plausible_web/components/billing/pageview_slider.ex
Normal file
160
lib/plausible_web/components/billing/pageview_slider.ex
Normal file
@ -0,0 +1,160 @@
|
||||
defmodule PlausibleWeb.Components.Billing.PageviewSlider do
|
||||
@moduledoc false
|
||||
|
||||
use Phoenix.Component
|
||||
use Phoenix.HTML
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.slider_output volume={@selected_volume} available_volumes={@available_volumes} />
|
||||
<.slider_input selected_volume={@selected_volume} available_volumes={@available_volumes} />
|
||||
<.slider_styles />
|
||||
"""
|
||||
end
|
||||
|
||||
attr :volume, :any
|
||||
attr :available_volumes, :list
|
||||
|
||||
defp slider_output(assigns) do
|
||||
~H"""
|
||||
<output class="lg:w-1/4 lg:order-1 font-medium text-lg text-gray-600 dark:text-gray-200">
|
||||
<span :if={@volume != :enterprise}>Up to</span>
|
||||
<strong id="slider-value" class="text-gray-900 dark:text-gray-100">
|
||||
<%= format_volume(@volume, @available_volumes) %>
|
||||
</strong>
|
||||
monthly pageviews
|
||||
</output>
|
||||
"""
|
||||
end
|
||||
|
||||
defp slider_input(assigns) do
|
||||
slider_labels =
|
||||
Enum.map(
|
||||
assigns.available_volumes ++ [:enterprise],
|
||||
&format_volume(&1, assigns.available_volumes)
|
||||
)
|
||||
|
||||
assigns = assign(assigns, :slider_labels, slider_labels)
|
||||
|
||||
~H"""
|
||||
<form class="max-w-md lg:max-w-none w-full lg:w-1/2 lg:order-2">
|
||||
<div class="flex items-baseline space-x-2">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-200">
|
||||
<%= List.first(@slider_labels) %>
|
||||
</span>
|
||||
<div class="flex-1 relative">
|
||||
<input
|
||||
phx-change="slide"
|
||||
id="slider"
|
||||
name="slider"
|
||||
class="shadow mt-8 dark:bg-gray-600 dark:border-none"
|
||||
type="range"
|
||||
min="0"
|
||||
max={length(@available_volumes)}
|
||||
step="1"
|
||||
value={
|
||||
Enum.find_index(@available_volumes, &(&1 == @selected_volume)) ||
|
||||
length(@available_volumes)
|
||||
}
|
||||
oninput="repositionBubble()"
|
||||
/>
|
||||
<output
|
||||
id="slider-bubble"
|
||||
class="absolute bottom-[35px] py-[4px] px-[12px] -translate-x-1/2 rounded-md text-white bg-indigo-600 position text-xs font-medium"
|
||||
phx-update="ignore"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-200">
|
||||
<%= List.last(@slider_labels) %>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const SLIDER_LABELS = <%= raw Jason.encode!(@slider_labels) %>
|
||||
|
||||
function repositionBubble() {
|
||||
const input = document.getElementById("slider")
|
||||
const percentage = Number((input.value / input.max) * 100)
|
||||
const bubble = document.getElementById("slider-bubble")
|
||||
|
||||
bubble.innerHTML = SLIDER_LABELS[input.value]
|
||||
bubble.style.left = `calc(${percentage}% + (${13.87 - percentage * 0.26}px))`
|
||||
}
|
||||
|
||||
repositionBubble()
|
||||
</script>
|
||||
"""
|
||||
end
|
||||
|
||||
defp format_volume(volume, available_volumes) do
|
||||
if volume == :enterprise do
|
||||
available_volumes
|
||||
|> List.last()
|
||||
|> PlausibleWeb.StatsView.large_number_format()
|
||||
|> Kernel.<>("+")
|
||||
else
|
||||
PlausibleWeb.StatsView.large_number_format(volume)
|
||||
end
|
||||
end
|
||||
|
||||
defp slider_styles(assigns) do
|
||||
~H"""
|
||||
<style>
|
||||
input[type="range"] {
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: white;
|
||||
border-radius: 3px;
|
||||
height: 6px;
|
||||
width: 100%;
|
||||
margin-bottom: 9px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: #5f48ff;
|
||||
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2212%22%20height%3D%228%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M8%20.5v7L12%204zM0%204l4%203.5v-7z%22%20fill%3D%22%23FFFFFF%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
background-color: #5f48ff;
|
||||
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2212%22%20height%3D%228%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M8%20.5v7L12%204zM0%204l4%203.5v-7z%22%20fill%3D%22%23FFFFFF%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
}
|
||||
|
||||
input[type="range"]::-ms-thumb {
|
||||
background-color: #5f48ff;
|
||||
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2212%22%20height%3D%228%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M8%20.5v7L12%204zM0%204l4%203.5v-7z%22%20fill%3D%22%23FFFFFF%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-focus-outer {
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
end
|
||||
end
|
136
lib/plausible_web/components/billing/plan_benefits.ex
Normal file
136
lib/plausible_web/components/billing/plan_benefits.ex
Normal file
@ -0,0 +1,136 @@
|
||||
defmodule PlausibleWeb.Components.Billing.PlanBenefits do
|
||||
@moduledoc """
|
||||
This module exposes functions for rendering and returning plan
|
||||
benefits for Growth, Business, and Enterprise plans.
|
||||
"""
|
||||
|
||||
use Phoenix.Component
|
||||
alias Plausible.Billing.Plan
|
||||
|
||||
attr :benefits, :list, required: true
|
||||
attr :class, :string, default: nil
|
||||
|
||||
@doc """
|
||||
This function takes a list of benefits returned by either one of:
|
||||
|
||||
* `for_growth/1`
|
||||
* `for_business/2`
|
||||
* `for_enterprise/1`.
|
||||
|
||||
and renders them as HTML.
|
||||
|
||||
The benefits in the given list can be either strings or functions
|
||||
returning a Phoenix component. This allows, for example, to render
|
||||
links within the plan benefit text.
|
||||
"""
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<ul role="list" class={["mt-8 space-y-3 text-sm leading-6 xl:mt-10", @class]}>
|
||||
<li :for={benefit <- @benefits} class="flex gap-x-3">
|
||||
<Heroicons.check class="h-6 w-5 text-indigo-600 dark:text-green-600" />
|
||||
<%= if is_binary(benefit), do: benefit, else: benefit.(assigns) %>
|
||||
</li>
|
||||
</ul>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
This function takes a growth plan and returns a list representing
|
||||
the different benefits a user gets when subscribing to this plan.
|
||||
"""
|
||||
def for_growth(plan) do
|
||||
[
|
||||
team_member_limit_benefit(plan),
|
||||
site_limit_benefit(plan),
|
||||
data_retention_benefit(plan),
|
||||
"Intuitive, fast and privacy-friendly dashboard",
|
||||
"Email/Slack reports",
|
||||
"Google Analytics import"
|
||||
]
|
||||
|> Kernel.++(feature_benefits(plan))
|
||||
|> Enum.filter(& &1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns Business benefits for the given Business plan.
|
||||
|
||||
A second argument is also required - list of Growth benefits. This
|
||||
is because we don't want to list the same benefits in both Growth
|
||||
and Business. Everything in Growth is also included in Business.
|
||||
"""
|
||||
def for_business(plan, growth_benefits) do
|
||||
[
|
||||
"Everything in Growth",
|
||||
team_member_limit_benefit(plan),
|
||||
site_limit_benefit(plan),
|
||||
data_retention_benefit(plan)
|
||||
]
|
||||
|> Kernel.++(feature_benefits(plan))
|
||||
|> Kernel.--(growth_benefits)
|
||||
|> Kernel.++(["Priority support"])
|
||||
|> Enum.filter(& &1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
This function only takes a list of business benefits. Since all
|
||||
limits and features of enterprise plans are configurable, we can
|
||||
say on the upgrade page that enterprise plans include everything
|
||||
in Business.
|
||||
"""
|
||||
def for_enterprise(business_benefits) do
|
||||
team_members =
|
||||
if "Up to 10 team members" in business_benefits, do: "10+ team members"
|
||||
|
||||
data_retention =
|
||||
if "5 years of data retention" in business_benefits, do: "5+ years of data retention"
|
||||
|
||||
[
|
||||
"Everything in Business",
|
||||
team_members,
|
||||
"50+ sites",
|
||||
"600+ Stats API requests per hour",
|
||||
&sites_api_benefit/1,
|
||||
data_retention,
|
||||
"Technical onboarding"
|
||||
]
|
||||
|> Enum.filter(& &1)
|
||||
end
|
||||
|
||||
defp data_retention_benefit(%Plan{} = plan) do
|
||||
if plan.data_retention_in_years, do: "#{plan.data_retention_in_years} years of data retention"
|
||||
end
|
||||
|
||||
defp team_member_limit_benefit(%Plan{} = plan) do
|
||||
case plan.team_member_limit do
|
||||
:unlimited -> "Unlimited team members"
|
||||
number -> "Up to #{number} team members"
|
||||
end
|
||||
end
|
||||
|
||||
defp site_limit_benefit(%Plan{} = plan), do: "Up to #{plan.site_limit} sites"
|
||||
|
||||
defp feature_benefits(%Plan{} = plan) do
|
||||
Enum.map(plan.features, fn feature_mod ->
|
||||
case feature_mod.name() do
|
||||
:goals -> "Goals and custom events"
|
||||
:stats_api -> "Stats API (600 requests per hour)"
|
||||
:revenue_goals -> "Ecommerce revenue attribution"
|
||||
_ -> feature_mod.display_name()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp sites_api_benefit(assigns) do
|
||||
~H"""
|
||||
<p>
|
||||
Sites API access for
|
||||
<.link
|
||||
class="text-indigo-500 hover:text-indigo-400"
|
||||
href="https://plausible.io/white-label-web-analytics"
|
||||
>
|
||||
reselling
|
||||
</.link>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
end
|
293
lib/plausible_web/components/billing/plan_box.ex
Normal file
293
lib/plausible_web/components/billing/plan_box.ex
Normal file
@ -0,0 +1,293 @@
|
||||
defmodule PlausibleWeb.Components.Billing.PlanBox do
|
||||
@moduledoc false
|
||||
|
||||
use Phoenix.Component
|
||||
require Plausible.Billing.Subscription.Status
|
||||
alias PlausibleWeb.Components.Billing.{PlanBenefits, Notice}
|
||||
alias Plausible.Billing.{Plan, Quota, Subscription}
|
||||
alias PlausibleWeb.Router.Helpers, as: Routes
|
||||
|
||||
def standard(assigns) do
|
||||
highlight =
|
||||
cond do
|
||||
assigns.owned -> "Current"
|
||||
assigns.recommended -> "Recommended"
|
||||
true -> nil
|
||||
end
|
||||
|
||||
assigns = assign(assigns, :highlight, highlight)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={"#{@kind}-plan-box"}
|
||||
class={[
|
||||
"shadow-lg bg-white rounded-3xl px-6 sm:px-8 py-4 sm:py-6 dark:bg-gray-800",
|
||||
!@highlight && "dark:ring-gray-600",
|
||||
@highlight && "ring-2 ring-indigo-600 dark:ring-indigo-300"
|
||||
]}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-x-4">
|
||||
<h3 class={[
|
||||
"text-lg font-semibold leading-8",
|
||||
!@highlight && "text-gray-900 dark:text-gray-100",
|
||||
@highlight && "text-indigo-600 dark:text-indigo-300"
|
||||
]}>
|
||||
<%= String.capitalize(to_string(@kind)) %>
|
||||
</h3>
|
||||
<.pill :if={@highlight} text={@highlight} />
|
||||
</div>
|
||||
<div>
|
||||
<.render_price_info available={@available} {assigns} />
|
||||
<%= if @available do %>
|
||||
<.checkout id={"#{@kind}-checkout"} {assigns} />
|
||||
<% else %>
|
||||
<.contact_button class="bg-indigo-600 hover:bg-indigo-500 text-white" />
|
||||
<% end %>
|
||||
</div>
|
||||
<%= if @owned && @kind == :growth && @plan_to_render.generation < 4 do %>
|
||||
<Notice.growth_grandfathered />
|
||||
<% else %>
|
||||
<PlanBenefits.render benefits={@benefits} class="text-gray-600 dark:text-gray-100" />
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def enterprise(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="enterprise-plan-box"
|
||||
class="rounded-3xl px-6 sm:px-8 py-4 sm:py-6 bg-gray-900 shadow-xl dark:bg-gray-800 dark:ring-gray-600"
|
||||
>
|
||||
<h3 class="text-lg font-semibold leading-8 text-white dark:text-gray-100">Enterprise</h3>
|
||||
<p class="mt-6 flex items-baseline gap-x-1">
|
||||
<span class="text-4xl font-bold tracking-tight text-white dark:text-gray-100">
|
||||
Custom
|
||||
</span>
|
||||
</p>
|
||||
<p class="h-4 mt-1"></p>
|
||||
<.contact_button class="" />
|
||||
<PlanBenefits.render benefits={@benefits} class="text-gray-300 dark:text-gray-100" />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp pill(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center justify-between gap-x-4">
|
||||
<p
|
||||
id="highlight-pill"
|
||||
class="rounded-full bg-indigo-600/10 px-2.5 py-1 text-xs font-semibold leading-5 text-indigo-600 dark:text-indigo-300 dark:ring-1 dark:ring-indigo-300/50"
|
||||
>
|
||||
<%= @text %>
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_price_info(%{available: false} = assigns) do
|
||||
~H"""
|
||||
<p id={"#{@kind}-custom-price"} class="mt-6 flex items-baseline gap-x-1">
|
||||
<span class="text-4xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
Custom
|
||||
</span>
|
||||
</p>
|
||||
<p class="h-4 mt-1"></p>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_price_info(assigns) do
|
||||
~H"""
|
||||
<p class="mt-6 flex items-baseline gap-x-1">
|
||||
<.price_tag
|
||||
kind={@kind}
|
||||
selected_interval={@selected_interval}
|
||||
plan_to_render={@plan_to_render}
|
||||
/>
|
||||
</p>
|
||||
<p class="mt-1 text-xs">+ VAT if applicable</p>
|
||||
"""
|
||||
end
|
||||
|
||||
defp price_tag(%{plan_to_render: %Plan{monthly_cost: nil}} = assigns) do
|
||||
~H"""
|
||||
<span class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
|
||||
N/A
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp price_tag(%{selected_interval: :monthly} = assigns) do
|
||||
~H"""
|
||||
<span
|
||||
id={"#{@kind}-price-tag-amount"}
|
||||
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<%= @plan_to_render.monthly_cost |> Plausible.Billing.format_price() %>
|
||||
</span>
|
||||
<span
|
||||
id={"#{@kind}-price-tag-interval"}
|
||||
class="text-sm font-semibold leading-6 text-gray-600 dark:text-gray-500"
|
||||
>
|
||||
/month
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp price_tag(%{selected_interval: :yearly} = assigns) do
|
||||
~H"""
|
||||
<span class="text-2xl font-bold w-max tracking-tight line-through text-gray-500 dark:text-gray-600 mr-1">
|
||||
<%= @plan_to_render.monthly_cost |> Money.mult!(12) |> Plausible.Billing.format_price() %>
|
||||
</span>
|
||||
<span
|
||||
id={"#{@kind}-price-tag-amount"}
|
||||
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<%= @plan_to_render.yearly_cost |> Plausible.Billing.format_price() %>
|
||||
</span>
|
||||
<span id={"#{@kind}-price-tag-interval"} class="text-sm font-semibold leading-6 text-gray-600">
|
||||
/year
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp checkout(assigns) do
|
||||
paddle_product_id = get_paddle_product_id(assigns.plan_to_render, assigns.selected_interval)
|
||||
change_plan_link_text = change_plan_link_text(assigns)
|
||||
|
||||
usage_within_limits =
|
||||
Quota.ensure_can_subscribe_to_plan(assigns.user, assigns.plan_to_render, assigns.usage) ==
|
||||
:ok
|
||||
|
||||
subscription = assigns.user.subscription
|
||||
|
||||
billing_details_expired =
|
||||
Subscription.Status.in?(subscription, [
|
||||
Subscription.Status.paused(),
|
||||
Subscription.Status.past_due()
|
||||
])
|
||||
|
||||
subscription_deleted = Subscription.Status.deleted?(subscription)
|
||||
|
||||
{checkout_disabled, disabled_message} =
|
||||
cond do
|
||||
assigns.usage.sites == 0 ->
|
||||
{true, nil}
|
||||
|
||||
change_plan_link_text == "Currently on this plan" && not subscription_deleted ->
|
||||
{true, nil}
|
||||
|
||||
assigns.available && !usage_within_limits ->
|
||||
{true, "Your usage exceeds this plan"}
|
||||
|
||||
billing_details_expired ->
|
||||
{true, "Please update your billing details first"}
|
||||
|
||||
true ->
|
||||
{false, nil}
|
||||
end
|
||||
|
||||
features_to_lose = assigns.usage.features -- assigns.plan_to_render.features
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:paddle_product_id, paddle_product_id)
|
||||
|> assign(:change_plan_link_text, change_plan_link_text)
|
||||
|> assign(:checkout_disabled, checkout_disabled)
|
||||
|> assign(:disabled_message, disabled_message)
|
||||
|> assign(:confirm_message, losing_features_message(features_to_lose))
|
||||
|
||||
~H"""
|
||||
<%= if @owned_plan && Plausible.Billing.Subscriptions.resumable?(@user.subscription) do %>
|
||||
<.change_plan_link {assigns} />
|
||||
<% else %>
|
||||
<PlausibleWeb.Components.Billing.paddle_button {assigns}>
|
||||
Upgrade
|
||||
</PlausibleWeb.Components.Billing.paddle_button>
|
||||
<% end %>
|
||||
<p :if={@disabled_message} class="h-0 text-center text-sm text-red-700 dark:text-red-500">
|
||||
<%= @disabled_message %>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_paddle_product_id(%Plan{monthly_product_id: plan_id}, :monthly), do: plan_id
|
||||
defp get_paddle_product_id(%Plan{yearly_product_id: plan_id}, :yearly), do: plan_id
|
||||
|
||||
defp change_plan_link_text(
|
||||
%{
|
||||
owned_plan: %Plan{kind: from_kind, monthly_pageview_limit: from_volume},
|
||||
plan_to_render: %Plan{kind: to_kind, monthly_pageview_limit: to_volume},
|
||||
current_interval: from_interval,
|
||||
selected_interval: to_interval
|
||||
} = _assigns
|
||||
) do
|
||||
cond do
|
||||
from_kind == :business && to_kind == :growth ->
|
||||
"Downgrade to Growth"
|
||||
|
||||
from_kind == :growth && to_kind == :business ->
|
||||
"Upgrade to Business"
|
||||
|
||||
from_volume == to_volume && from_interval == to_interval ->
|
||||
"Currently on this plan"
|
||||
|
||||
from_volume == to_volume ->
|
||||
"Change billing interval"
|
||||
|
||||
from_volume > to_volume ->
|
||||
"Downgrade"
|
||||
|
||||
true ->
|
||||
"Upgrade"
|
||||
end
|
||||
end
|
||||
|
||||
defp change_plan_link_text(_), do: nil
|
||||
|
||||
defp change_plan_link(assigns) do
|
||||
confirmed =
|
||||
if assigns.confirm_message, do: "confirm(\"#{assigns.confirm_message}\")", else: "true"
|
||||
|
||||
assigns = assign(assigns, :confirmed, confirmed)
|
||||
|
||||
~H"""
|
||||
<button
|
||||
id={"#{@kind}-checkout"}
|
||||
onclick={"if (#{@confirmed}) {window.location = '#{Routes.billing_path(PlausibleWeb.Endpoint, :change_plan_preview, @paddle_product_id)}'}"}
|
||||
class={[
|
||||
"w-full mt-6 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 text-white",
|
||||
!@checkout_disabled && "bg-indigo-600 hover:bg-indigo-500",
|
||||
@checkout_disabled && "pointer-events-none bg-gray-400 dark:bg-gray-600"
|
||||
]}
|
||||
>
|
||||
<%= @change_plan_link_text %>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp losing_features_message([]), do: nil
|
||||
|
||||
defp losing_features_message(features_to_lose) do
|
||||
features_list_str =
|
||||
features_to_lose
|
||||
|> Enum.map(& &1.display_name)
|
||||
|> PlausibleWeb.TextHelpers.pretty_join()
|
||||
|
||||
"This plan does not support #{features_list_str}, which you are currently using. Please note that by subscribing to this plan you will lose access to #{if length(features_to_lose) == 1, do: "this feature", else: "these features"}."
|
||||
end
|
||||
|
||||
defp contact_button(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
href="https://plausible.io/contact"
|
||||
class={[
|
||||
"mt-6 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 bg-gray-800 hover:bg-gray-700 text-white dark:bg-indigo-600 dark:hover:bg-indigo-500",
|
||||
@class
|
||||
]}
|
||||
>
|
||||
Contact us
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
end
|
@ -5,13 +5,11 @@ defmodule PlausibleWeb.Live.ChoosePlan do
|
||||
use Phoenix.LiveView
|
||||
use Phoenix.HTML
|
||||
|
||||
import PlausibleWeb.Components.Billing
|
||||
|
||||
require Plausible.Billing.Subscription.Status
|
||||
|
||||
alias PlausibleWeb.Components.Billing.{PlanBox, PlanBenefits, Notice, PageviewSlider}
|
||||
alias Plausible.Users
|
||||
alias Plausible.Billing.{Plans, Plan, Quota, Subscription}
|
||||
alias PlausibleWeb.Router.Helpers, as: Routes
|
||||
alias Plausible.Billing.{Plans, Plan, Quota}
|
||||
|
||||
@contact_link "https://plausible.io/contact"
|
||||
@billing_faq_link "https://plausible.io/docs/billing"
|
||||
@ -82,9 +80,9 @@ defmodule PlausibleWeb.Live.ChoosePlan do
|
||||
business_plan_to_render =
|
||||
assigns.selected_business_plan || List.last(assigns.available_plans.business)
|
||||
|
||||
growth_benefits = growth_benefits(growth_plan_to_render)
|
||||
|
||||
business_benefits = business_benefits(business_plan_to_render, growth_benefits)
|
||||
growth_benefits = PlanBenefits.for_growth(growth_plan_to_render)
|
||||
business_benefits = PlanBenefits.for_business(business_plan_to_render, growth_benefits)
|
||||
enterprise_benefits = PlanBenefits.for_enterprise(business_benefits)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
@ -92,14 +90,14 @@ defmodule PlausibleWeb.Live.ChoosePlan do
|
||||
|> assign(:business_plan_to_render, business_plan_to_render)
|
||||
|> assign(:growth_benefits, growth_benefits)
|
||||
|> assign(:business_benefits, business_benefits)
|
||||
|> assign(:enterprise_benefits, enterprise_benefits(business_benefits))
|
||||
|> assign(:enterprise_benefits, enterprise_benefits)
|
||||
|
||||
~H"""
|
||||
<div class="bg-gray-100 dark:bg-gray-900 pt-1 pb-12 sm:pb-16 text-gray-900 dark:text-gray-100">
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-20">
|
||||
<.subscription_past_due_notice class="pb-6" subscription={@user.subscription} />
|
||||
<.subscription_paused_notice class="pb-6" subscription={@user.subscription} />
|
||||
<.upgrade_ineligible_notice :if={@usage.sites == 0} />
|
||||
<Notice.subscription_past_due class="pb-6" subscription={@user.subscription} />
|
||||
<Notice.subscription_paused class="pb-6" subscription={@user.subscription} />
|
||||
<Notice.upgrade_ineligible :if={@usage.sites == 0} />
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<p class="text-4xl font-bold tracking-tight lg:text-5xl">
|
||||
<%= if @owned_plan,
|
||||
@ -109,11 +107,13 @@ defmodule PlausibleWeb.Live.ChoosePlan do
|
||||
</div>
|
||||
<div class="mt-12 flex flex-col gap-8 lg:flex-row items-center lg:items-baseline">
|
||||
<.interval_picker selected_interval={@selected_interval} />
|
||||
<.slider_output volume={@selected_volume} available_volumes={@available_volumes} />
|
||||
<.slider selected_volume={@selected_volume} available_volumes={@available_volumes} />
|
||||
<PageviewSlider.render
|
||||
selected_volume={@selected_volume}
|
||||
available_volumes={@available_volumes}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-6 isolate mx-auto grid max-w-md grid-cols-1 gap-8 lg:mx-0 lg:max-w-none lg:grid-cols-3">
|
||||
<.plan_box
|
||||
<PlanBox.standard
|
||||
kind={:growth}
|
||||
owned={@owned_tier == :growth}
|
||||
recommended={@recommended_tier == :growth}
|
||||
@ -122,7 +122,7 @@ defmodule PlausibleWeb.Live.ChoosePlan do
|
||||
available={!!@selected_growth_plan}
|
||||
{assigns}
|
||||
/>
|
||||
<.plan_box
|
||||
<PlanBox.standard
|
||||
kind={:business}
|
||||
owned={@owned_tier == :business}
|
||||
recommended={@recommended_tier == :business}
|
||||
@ -131,7 +131,7 @@ defmodule PlausibleWeb.Live.ChoosePlan do
|
||||
available={!!@selected_business_plan}
|
||||
{assigns}
|
||||
/>
|
||||
<.enterprise_plan_box benefits={@enterprise_benefits} />
|
||||
<PlanBox.enterprise benefits={@enterprise_benefits} />
|
||||
</div>
|
||||
<p class="mx-auto mt-8 max-w-2xl text-center text-lg leading-8 text-gray-600 dark:text-gray-400">
|
||||
You have used <b><%= PlausibleWeb.AuthView.delimit_integer(@last_30_days_usage) %></b>
|
||||
@ -141,8 +141,7 @@ defmodule PlausibleWeb.Live.ChoosePlan do
|
||||
<.help_links />
|
||||
</div>
|
||||
</div>
|
||||
<.slider_styles />
|
||||
<.paddle_script />
|
||||
<PlausibleWeb.Components.Billing.paddle_script />
|
||||
"""
|
||||
end
|
||||
|
||||
@ -231,316 +230,6 @@ defmodule PlausibleWeb.Live.ChoosePlan do
|
||||
"""
|
||||
end
|
||||
|
||||
defp slider(assigns) do
|
||||
slider_labels =
|
||||
Enum.map(
|
||||
assigns.available_volumes ++ [:enterprise],
|
||||
&format_volume(&1, assigns.available_volumes)
|
||||
)
|
||||
|
||||
assigns = assign(assigns, :slider_labels, slider_labels)
|
||||
|
||||
~H"""
|
||||
<form class="max-w-md lg:max-w-none w-full lg:w-1/2 lg:order-2">
|
||||
<div class="flex items-baseline space-x-2">
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-200">
|
||||
<%= List.first(@slider_labels) %>
|
||||
</span>
|
||||
<div class="flex-1 relative">
|
||||
<input
|
||||
phx-change="slide"
|
||||
id="slider"
|
||||
name="slider"
|
||||
class="shadow mt-8 dark:bg-gray-600 dark:border-none"
|
||||
type="range"
|
||||
min="0"
|
||||
max={length(@available_volumes)}
|
||||
step="1"
|
||||
value={
|
||||
Enum.find_index(@available_volumes, &(&1 == @selected_volume)) ||
|
||||
length(@available_volumes)
|
||||
}
|
||||
oninput="repositionBubble()"
|
||||
/>
|
||||
<output
|
||||
id="slider-bubble"
|
||||
class="absolute bottom-[35px] py-[4px] px-[12px] -translate-x-1/2 rounded-md text-white bg-indigo-600 position text-xs font-medium"
|
||||
phx-update="ignore"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-200">
|
||||
<%= List.last(@slider_labels) %>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const SLIDER_LABELS = <%= raw Jason.encode!(@slider_labels) %>
|
||||
|
||||
function repositionBubble() {
|
||||
const input = document.getElementById("slider")
|
||||
const percentage = Number((input.value / input.max) * 100)
|
||||
const bubble = document.getElementById("slider-bubble")
|
||||
|
||||
bubble.innerHTML = SLIDER_LABELS[input.value]
|
||||
bubble.style.left = `calc(${percentage}% + (${13.87 - percentage * 0.26}px))`
|
||||
}
|
||||
|
||||
repositionBubble()
|
||||
</script>
|
||||
"""
|
||||
end
|
||||
|
||||
defp plan_box(assigns) do
|
||||
highlight =
|
||||
cond do
|
||||
assigns.owned -> "Current"
|
||||
assigns.recommended -> "Recommended"
|
||||
true -> nil
|
||||
end
|
||||
|
||||
assigns = assign(assigns, :highlight, highlight)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
id={"#{@kind}-plan-box"}
|
||||
class={[
|
||||
"shadow-lg bg-white rounded-3xl px-6 sm:px-8 py-4 sm:py-6 dark:bg-gray-800",
|
||||
!@highlight && "dark:ring-gray-600",
|
||||
@highlight && "ring-2 ring-indigo-600 dark:ring-indigo-300"
|
||||
]}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-x-4">
|
||||
<h3 class={[
|
||||
"text-lg font-semibold leading-8",
|
||||
!@highlight && "text-gray-900 dark:text-gray-100",
|
||||
@highlight && "text-indigo-600 dark:text-indigo-300"
|
||||
]}>
|
||||
<%= String.capitalize(to_string(@kind)) %>
|
||||
</h3>
|
||||
<.pill :if={@highlight} text={@highlight} />
|
||||
</div>
|
||||
<div>
|
||||
<.render_price_info available={@available} {assigns} />
|
||||
<%= if @available do %>
|
||||
<.checkout id={"#{@kind}-checkout"} {assigns} />
|
||||
<% else %>
|
||||
<.contact_button class="bg-indigo-600 hover:bg-indigo-500 text-white" />
|
||||
<% end %>
|
||||
</div>
|
||||
<%= if @owned && @kind == :growth && @plan_to_render.generation < 4 do %>
|
||||
<.growth_grandfathering_notice />
|
||||
<% else %>
|
||||
<ul
|
||||
role="list"
|
||||
class="mt-8 space-y-3 text-sm leading-6 text-gray-600 dark:text-gray-100 xl:mt-10"
|
||||
>
|
||||
<.plan_benefit :for={benefit <- @benefits}><%= benefit %></.plan_benefit>
|
||||
</ul>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp checkout(assigns) do
|
||||
paddle_product_id = get_paddle_product_id(assigns.plan_to_render, assigns.selected_interval)
|
||||
change_plan_link_text = change_plan_link_text(assigns)
|
||||
|
||||
usage_within_limits =
|
||||
Quota.ensure_can_subscribe_to_plan(assigns.user, assigns.plan_to_render, assigns.usage) ==
|
||||
:ok
|
||||
|
||||
subscription = assigns.user.subscription
|
||||
|
||||
billing_details_expired =
|
||||
Subscription.Status.in?(subscription, [
|
||||
Subscription.Status.paused(),
|
||||
Subscription.Status.past_due()
|
||||
])
|
||||
|
||||
subscription_deleted = Subscription.Status.deleted?(subscription)
|
||||
|
||||
{checkout_disabled, disabled_message} =
|
||||
cond do
|
||||
assigns.usage.sites == 0 ->
|
||||
{true, nil}
|
||||
|
||||
change_plan_link_text == "Currently on this plan" && not subscription_deleted ->
|
||||
{true, nil}
|
||||
|
||||
assigns.available && !usage_within_limits ->
|
||||
{true, "Your usage exceeds this plan"}
|
||||
|
||||
billing_details_expired ->
|
||||
{true, "Please update your billing details first"}
|
||||
|
||||
true ->
|
||||
{false, nil}
|
||||
end
|
||||
|
||||
features_to_lose = assigns.usage.features -- assigns.plan_to_render.features
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:paddle_product_id, paddle_product_id)
|
||||
|> assign(:change_plan_link_text, change_plan_link_text)
|
||||
|> assign(:checkout_disabled, checkout_disabled)
|
||||
|> assign(:disabled_message, disabled_message)
|
||||
|> assign(:confirm_message, losing_features_message(features_to_lose))
|
||||
|
||||
~H"""
|
||||
<%= if @owned_plan && Plausible.Billing.Subscriptions.resumable?(@user.subscription) do %>
|
||||
<.change_plan_link {assigns} />
|
||||
<% else %>
|
||||
<.paddle_button {assigns}>Upgrade</.paddle_button>
|
||||
<% end %>
|
||||
<p :if={@disabled_message} class="h-0 text-center text-sm text-red-700 dark:text-red-500">
|
||||
<%= @disabled_message %>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
defp losing_features_message([]), do: nil
|
||||
|
||||
defp losing_features_message(features_to_lose) do
|
||||
features_list_str =
|
||||
features_to_lose
|
||||
|> Enum.map(& &1.display_name)
|
||||
|> PlausibleWeb.TextHelpers.pretty_join()
|
||||
|
||||
"This plan does not support #{features_list_str}, which you are currently using. Please note that by subscribing to this plan you will lose access to #{if length(features_to_lose) == 1, do: "this feature", else: "these features"}."
|
||||
end
|
||||
|
||||
defp growth_grandfathering_notice(assigns) do
|
||||
~H"""
|
||||
<ul class="mt-8 space-y-3 text-sm leading-6 text-gray-600 text-justify dark:text-gray-100 xl:mt-10">
|
||||
Your subscription has been grandfathered in at the same rate and terms as when you first joined. If you don't need the "Business" features, you're welcome to stay on this plan. You can adjust the pageview limit or change the billing frequency of this grandfathered plan. If you're interested in business features, you can upgrade to the new "Business" plan.
|
||||
</ul>
|
||||
"""
|
||||
end
|
||||
|
||||
def render_price_info(%{available: false} = assigns) do
|
||||
~H"""
|
||||
<p id={"#{@kind}-custom-price"} class="mt-6 flex items-baseline gap-x-1">
|
||||
<span class="text-4xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
Custom
|
||||
</span>
|
||||
</p>
|
||||
<p class="h-4 mt-1"></p>
|
||||
"""
|
||||
end
|
||||
|
||||
def render_price_info(assigns) do
|
||||
~H"""
|
||||
<p class="mt-6 flex items-baseline gap-x-1">
|
||||
<.price_tag
|
||||
kind={@kind}
|
||||
selected_interval={@selected_interval}
|
||||
plan_to_render={@plan_to_render}
|
||||
/>
|
||||
</p>
|
||||
<p class="mt-1 text-xs">+ VAT if applicable</p>
|
||||
"""
|
||||
end
|
||||
|
||||
defp change_plan_link(assigns) do
|
||||
confirmed =
|
||||
if assigns.confirm_message, do: "confirm(\"#{assigns.confirm_message}\")", else: "true"
|
||||
|
||||
assigns = assign(assigns, :confirmed, confirmed)
|
||||
|
||||
~H"""
|
||||
<button
|
||||
id={"#{@kind}-checkout"}
|
||||
onclick={"if (#{@confirmed}) {window.location = '#{Routes.billing_path(PlausibleWeb.Endpoint, :change_plan_preview, @paddle_product_id)}'}"}
|
||||
class={[
|
||||
"w-full mt-6 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 text-white",
|
||||
!@checkout_disabled && "bg-indigo-600 hover:bg-indigo-500",
|
||||
@checkout_disabled && "pointer-events-none bg-gray-400 dark:bg-gray-600"
|
||||
]}
|
||||
>
|
||||
<%= @change_plan_link_text %>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
slot :inner_block, required: true
|
||||
attr :icon_color, :string, default: "indigo-600"
|
||||
|
||||
defp plan_benefit(assigns) do
|
||||
~H"""
|
||||
<li class="flex gap-x-3">
|
||||
<.check_icon class={"text-#{@icon_color} dark:text-green-600"} />
|
||||
<%= render_slot(@inner_block) %>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
defp contact_button(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
href={contact_link()}
|
||||
class={[
|
||||
"mt-6 block rounded-md py-2 px-3 text-center text-sm font-semibold leading-6 bg-gray-800 hover:bg-gray-700 text-white dark:bg-indigo-600 dark:hover:bg-indigo-500",
|
||||
@class
|
||||
]}
|
||||
>
|
||||
Contact us
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
|
||||
defp enterprise_plan_box(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="enterprise-plan-box"
|
||||
class="rounded-3xl px-6 sm:px-8 py-4 sm:py-6 bg-gray-900 shadow-xl dark:bg-gray-800 dark:ring-gray-600"
|
||||
>
|
||||
<h3 class="text-lg font-semibold leading-8 text-white dark:text-gray-100">Enterprise</h3>
|
||||
<p class="mt-6 flex items-baseline gap-x-1">
|
||||
<span class="text-4xl font-bold tracking-tight text-white dark:text-gray-100">
|
||||
Custom
|
||||
</span>
|
||||
</p>
|
||||
<p class="h-4 mt-1"></p>
|
||||
<.contact_button class="" />
|
||||
<ul
|
||||
role="list"
|
||||
class="mt-8 space-y-3 text-sm leading-6 xl:mt-10 text-gray-300 dark:text-gray-100"
|
||||
>
|
||||
<.plan_benefit :for={benefit <- @benefits}>
|
||||
<%= if is_binary(benefit), do: benefit, else: benefit.(assigns) %>
|
||||
</.plan_benefit>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp pill(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center justify-between gap-x-4">
|
||||
<p
|
||||
id="highlight-pill"
|
||||
class="rounded-full bg-indigo-600/10 px-2.5 py-1 text-xs font-semibold leading-5 text-indigo-600 dark:text-indigo-300 dark:ring-1 dark:ring-indigo-300/50"
|
||||
>
|
||||
<%= @text %>
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp check_icon(assigns) do
|
||||
~H"""
|
||||
<svg {%{class: "h-6 w-5 flex-none #{@class}", viewBox: "0 0 20 20",fill: "currentColor","aria-hidden": "true"}}>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
defp pageview_limit_notice(assigns) do
|
||||
~H"""
|
||||
<div class="mt-12 mx-auto mt-6 max-w-2xl">
|
||||
@ -569,139 +258,6 @@ defmodule PlausibleWeb.Live.ChoosePlan do
|
||||
"""
|
||||
end
|
||||
|
||||
defp price_tag(%{plan_to_render: %Plan{monthly_cost: nil}} = assigns) do
|
||||
~H"""
|
||||
<span class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
|
||||
N/A
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp price_tag(%{selected_interval: :monthly} = assigns) do
|
||||
~H"""
|
||||
<span
|
||||
id={"#{@kind}-price-tag-amount"}
|
||||
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<%= @plan_to_render.monthly_cost |> format_price() %>
|
||||
</span>
|
||||
<span
|
||||
id={"#{@kind}-price-tag-interval"}
|
||||
class="text-sm font-semibold leading-6 text-gray-600 dark:text-gray-500"
|
||||
>
|
||||
/month
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp price_tag(%{selected_interval: :yearly} = assigns) do
|
||||
~H"""
|
||||
<span class="text-2xl font-bold w-max tracking-tight line-through text-gray-500 dark:text-gray-600 mr-1">
|
||||
<%= @plan_to_render.monthly_cost |> Money.mult!(12) |> format_price() %>
|
||||
</span>
|
||||
<span
|
||||
id={"#{@kind}-price-tag-amount"}
|
||||
class="text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<%= @plan_to_render.yearly_cost |> format_price() %>
|
||||
</span>
|
||||
<span id={"#{@kind}-price-tag-interval"} class="text-sm font-semibold leading-6 text-gray-600">
|
||||
/year
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp slider_styles(assigns) do
|
||||
~H"""
|
||||
<style>
|
||||
input[type="range"] {
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: white;
|
||||
border-radius: 3px;
|
||||
height: 6px;
|
||||
width: 100%;
|
||||
margin-bottom: 9px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-color: #5f48ff;
|
||||
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2212%22%20height%3D%228%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M8%20.5v7L12%204zM0%204l4%203.5v-7z%22%20fill%3D%22%23FFFFFF%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
background-color: #5f48ff;
|
||||
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2212%22%20height%3D%228%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M8%20.5v7L12%204zM0%204l4%203.5v-7z%22%20fill%3D%22%23FFFFFF%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
}
|
||||
|
||||
input[type="range"]::-ms-thumb {
|
||||
background-color: #5f48ff;
|
||||
background-image: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2212%22%20height%3D%228%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M8%20.5v7L12%204zM0%204l4%203.5v-7z%22%20fill%3D%22%23FFFFFF%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-focus-outer {
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
"""
|
||||
end
|
||||
|
||||
defp change_plan_link_text(
|
||||
%{
|
||||
owned_plan: %Plan{kind: from_kind, monthly_pageview_limit: from_volume},
|
||||
plan_to_render: %Plan{kind: to_kind, monthly_pageview_limit: to_volume},
|
||||
current_interval: from_interval,
|
||||
selected_interval: to_interval
|
||||
} = _assigns
|
||||
) do
|
||||
cond do
|
||||
from_kind == :business && to_kind == :growth ->
|
||||
"Downgrade to Growth"
|
||||
|
||||
from_kind == :growth && to_kind == :business ->
|
||||
"Upgrade to Business"
|
||||
|
||||
from_volume == to_volume && from_interval == to_interval ->
|
||||
"Currently on this plan"
|
||||
|
||||
from_volume == to_volume ->
|
||||
"Change billing interval"
|
||||
|
||||
from_volume > to_volume ->
|
||||
"Downgrade"
|
||||
|
||||
true ->
|
||||
"Upgrade"
|
||||
end
|
||||
end
|
||||
|
||||
defp change_plan_link_text(_), do: nil
|
||||
|
||||
defp get_available_volumes(%{business: business_plans, growth: growth_plans}) do
|
||||
growth_volumes = Enum.map(growth_plans, & &1.monthly_pageview_limit)
|
||||
business_volumes = Enum.map(business_plans, & &1.monthly_pageview_limit)
|
||||
@ -710,118 +266,6 @@ defmodule PlausibleWeb.Live.ChoosePlan do
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
defp get_paddle_product_id(%Plan{monthly_product_id: plan_id}, :monthly), do: plan_id
|
||||
defp get_paddle_product_id(%Plan{yearly_product_id: plan_id}, :yearly), do: plan_id
|
||||
|
||||
attr :volume, :any
|
||||
attr :available_volumes, :list
|
||||
|
||||
defp slider_output(assigns) do
|
||||
~H"""
|
||||
<output class="lg:w-1/4 lg:order-1 font-medium text-lg text-gray-600 dark:text-gray-200">
|
||||
<span :if={@volume != :enterprise}>Up to</span>
|
||||
<strong id="slider-value" class="text-gray-900 dark:text-gray-100">
|
||||
<%= format_volume(@volume, @available_volumes) %>
|
||||
</strong>
|
||||
monthly pageviews
|
||||
</output>
|
||||
"""
|
||||
end
|
||||
|
||||
defp format_volume(volume, available_volumes) do
|
||||
if volume == :enterprise do
|
||||
available_volumes
|
||||
|> List.last()
|
||||
|> PlausibleWeb.StatsView.large_number_format()
|
||||
|> Kernel.<>("+")
|
||||
else
|
||||
PlausibleWeb.StatsView.large_number_format(volume)
|
||||
end
|
||||
end
|
||||
|
||||
defp growth_benefits(plan) do
|
||||
[
|
||||
team_member_limit_benefit(plan),
|
||||
site_limit_benefit(plan),
|
||||
data_retention_benefit(plan),
|
||||
"Intuitive, fast and privacy-friendly dashboard",
|
||||
"Email/Slack reports",
|
||||
"Google Analytics import"
|
||||
]
|
||||
|> Kernel.++(feature_benefits(plan))
|
||||
|> Enum.filter(& &1)
|
||||
end
|
||||
|
||||
defp business_benefits(plan, growth_benefits) do
|
||||
[
|
||||
"Everything in Growth",
|
||||
team_member_limit_benefit(plan),
|
||||
site_limit_benefit(plan),
|
||||
data_retention_benefit(plan)
|
||||
]
|
||||
|> Kernel.++(feature_benefits(plan))
|
||||
|> Kernel.--(growth_benefits)
|
||||
|> Kernel.++(["Priority support"])
|
||||
|> Enum.filter(& &1)
|
||||
end
|
||||
|
||||
defp enterprise_benefits(business_benefits) do
|
||||
team_members =
|
||||
if "Up to 10 team members" in business_benefits, do: "10+ team members"
|
||||
|
||||
data_retention =
|
||||
if "5 years of data retention" in business_benefits, do: "5+ years of data retention"
|
||||
|
||||
[
|
||||
"Everything in Business",
|
||||
team_members,
|
||||
"50+ sites",
|
||||
"600+ Stats API requests per hour",
|
||||
&sites_api_benefit/1,
|
||||
data_retention,
|
||||
"Technical onboarding"
|
||||
]
|
||||
|> Enum.filter(& &1)
|
||||
end
|
||||
|
||||
defp data_retention_benefit(%Plan{} = plan) do
|
||||
if plan.data_retention_in_years, do: "#{plan.data_retention_in_years} years of data retention"
|
||||
end
|
||||
|
||||
defp team_member_limit_benefit(%Plan{} = plan) do
|
||||
case plan.team_member_limit do
|
||||
:unlimited -> "Unlimited team members"
|
||||
number -> "Up to #{number} team members"
|
||||
end
|
||||
end
|
||||
|
||||
defp site_limit_benefit(%Plan{} = plan), do: "Up to #{plan.site_limit} sites"
|
||||
|
||||
defp feature_benefits(%Plan{} = plan) do
|
||||
Enum.map(plan.features, fn feature_mod ->
|
||||
case feature_mod.name() do
|
||||
:goals -> "Goals and custom events"
|
||||
:stats_api -> "Stats API (600 requests per hour)"
|
||||
:revenue_goals -> "Ecommerce revenue attribution"
|
||||
_ -> feature_mod.display_name()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp sites_api_benefit(assigns) do
|
||||
~H"""
|
||||
<p>
|
||||
Sites API access for
|
||||
<.link
|
||||
class="text-indigo-500 hover:text-indigo-400"
|
||||
href="https://plausible.io/white-label-web-analytics"
|
||||
>
|
||||
reselling
|
||||
</.link>
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
defp contact_link(), do: @contact_link
|
||||
|
||||
defp billing_faq_link(), do: @billing_faq_link
|
||||
|
@ -154,7 +154,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlausibleWeb.Components.Billing.premium_feature_notice
|
||||
<PlausibleWeb.Components.Billing.Notice.premium_feature
|
||||
billable_user={@site.owner}
|
||||
current_user={@current_user}
|
||||
feature_mod={Plausible.Billing.Feature.RevenueGoals}
|
||||
|
@ -26,7 +26,7 @@
|
||||
|
||||
<div class="my-4 border-b border-gray-400"></div>
|
||||
|
||||
<PlausibleWeb.Components.Billing.subscription_cancelled_notice
|
||||
<PlausibleWeb.Components.Billing.Notice.subscription_cancelled
|
||||
user={@user}
|
||||
dismissable={false}
|
||||
/>
|
||||
@ -311,7 +311,7 @@
|
||||
<h2 class="text-xl font-black dark:text-gray-100">API Keys</h2>
|
||||
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
|
||||
|
||||
<PlausibleWeb.Components.Billing.premium_feature_notice
|
||||
<PlausibleWeb.Components.Billing.Notice.premium_feature
|
||||
billable_user={@current_user}
|
||||
current_user={@current_user}
|
||||
feature_mod={Plausible.Billing.Feature.StatsAPI}
|
||||
|
@ -22,7 +22,7 @@
|
||||
The plan is priced at
|
||||
<b>
|
||||
<%= case @price do
|
||||
%Money{} = money -> PlausibleWeb.Components.Billing.format_price(money)
|
||||
%Money{} = money -> Plausible.Billing.format_price(money)
|
||||
nil -> "N/A"
|
||||
end %>
|
||||
</b>
|
||||
|
@ -97,14 +97,16 @@
|
||||
|
||||
<%= if @conn.assigns[:current_user] do %>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<.subscription_cancelled_notice user={@conn.assigns.current_user} />
|
||||
<PlausibleWeb.Components.Billing.Notice.subscription_cancelled user={
|
||||
@conn.assigns.current_user
|
||||
} />
|
||||
|
||||
<.subscription_past_due_notice
|
||||
<PlausibleWeb.Components.Billing.Notice.subscription_past_due
|
||||
subscription={@conn.assigns.current_user.subscription}
|
||||
class="container"
|
||||
/>
|
||||
|
||||
<.subscription_paused_notice
|
||||
<PlausibleWeb.Components.Billing.Notice.subscription_paused
|
||||
subscription={@conn.assigns.current_user.subscription}
|
||||
class="container"
|
||||
/>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<%= form_for @conn, Routes.membership_path(@conn, :invite_member, @site.domain), [class: "max-w-lg w-full mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>
|
||||
<h2 class="text-xl font-black dark:text-gray-100 mb-4">Invite member to <%= @site.domain %></h2>
|
||||
|
||||
<PlausibleWeb.Components.Billing.limit_exceeded_notice
|
||||
<PlausibleWeb.Components.Billing.Notice.limit_exceeded
|
||||
:if={Map.get(assigns, :is_at_limit, false)}
|
||||
current_user={@current_user}
|
||||
billable_user={@site.owner}
|
||||
|
@ -2,7 +2,7 @@
|
||||
<%= form_for @changeset, "/sites", [class: "max-w-lg w-full mx-auto bg-white dark:bg-gray-800 shadow-lg rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>
|
||||
<h2 class="text-xl font-black dark:text-gray-100 mb-4">Your website details</h2>
|
||||
|
||||
<PlausibleWeb.Components.Billing.limit_exceeded_notice
|
||||
<PlausibleWeb.Components.Billing.Notice.limit_exceeded
|
||||
:if={@site_limit_exceeded?}
|
||||
current_user={@current_user}
|
||||
billable_user={@current_user}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<section class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden">
|
||||
<PlausibleWeb.Components.Billing.premium_feature_notice
|
||||
<PlausibleWeb.Components.Billing.Notice.premium_feature
|
||||
billable_user={@site.owner}
|
||||
current_user={@current_user}
|
||||
feature_mod={Plausible.Billing.Feature.Funnels}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<section class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden">
|
||||
<PlausibleWeb.Components.Billing.premium_feature_notice
|
||||
<PlausibleWeb.Components.Billing.Notice.premium_feature
|
||||
billable_user={@site.owner}
|
||||
current_user={@current_user}
|
||||
feature_mod={Plausible.Billing.Feature.Props}
|
||||
|
@ -2,8 +2,6 @@ defmodule PlausibleWeb.LayoutView do
|
||||
use PlausibleWeb, :view
|
||||
use Plausible
|
||||
|
||||
import PlausibleWeb.Components.Billing
|
||||
|
||||
def plausible_url do
|
||||
PlausibleWeb.Endpoint.url()
|
||||
end
|
||||
|
@ -1,23 +1,23 @@
|
||||
defmodule PlausibleWeb.Components.BillingTest do
|
||||
defmodule PlausibleWeb.Components.Billing.NoticeTest do
|
||||
use Plausible.DataCase
|
||||
import Phoenix.LiveViewTest
|
||||
alias PlausibleWeb.Components.Billing
|
||||
alias PlausibleWeb.Components.Billing.Notice
|
||||
|
||||
test "premium_feature_notice/1 does not render a notice when user is on trial" do
|
||||
test "premium_feature/1 does not render a notice when user is on trial" do
|
||||
me = insert(:user)
|
||||
|
||||
assert render_component(&Billing.premium_feature_notice/1,
|
||||
assert render_component(&Notice.premium_feature/1,
|
||||
billable_user: me,
|
||||
current_user: me,
|
||||
feature_mod: Plausible.Billing.Feature.Props
|
||||
) == ""
|
||||
end
|
||||
|
||||
test "premium_feature_notice/1 renders an upgrade link when user is the site owner and does not have access to the feature" do
|
||||
test "premium_feature/1 renders an upgrade link when user is the site owner and does not have access to the feature" do
|
||||
me = insert(:user, subscription: build(:growth_subscription))
|
||||
|
||||
rendered =
|
||||
render_component(&Billing.premium_feature_notice/1,
|
||||
render_component(&Notice.premium_feature/1,
|
||||
billable_user: me,
|
||||
current_user: me,
|
||||
feature_mod: Plausible.Billing.Feature.Props
|
||||
@ -28,12 +28,12 @@ defmodule PlausibleWeb.Components.BillingTest do
|
||||
assert rendered =~ "/billing/choose-plan"
|
||||
end
|
||||
|
||||
test "premium_feature_notice/1 does not render an upgrade link when user is not the site owner" do
|
||||
test "premium_feature/1 does not render an upgrade link when user is not the site owner" do
|
||||
me = insert(:user)
|
||||
owner = insert(:user, subscription: build(:growth_subscription))
|
||||
|
||||
rendered =
|
||||
render_component(&Billing.premium_feature_notice/1,
|
||||
render_component(&Notice.premium_feature/1,
|
||||
billable_user: owner,
|
||||
current_user: me,
|
||||
feature_mod: Plausible.Billing.Feature.Funnels
|
||||
@ -43,11 +43,11 @@ defmodule PlausibleWeb.Components.BillingTest do
|
||||
assert rendered =~ "please reach out to the site owner to upgrade their subscription"
|
||||
end
|
||||
|
||||
test "premium_feature_notice/1 does not render a notice when the user has access to the feature" do
|
||||
test "premium_feature/1 does not render a notice when the user has access to the feature" do
|
||||
me = insert(:user, subscription: build(:business_subscription))
|
||||
|
||||
rendered =
|
||||
render_component(&Billing.premium_feature_notice/1,
|
||||
render_component(&Notice.premium_feature/1,
|
||||
billable_user: me,
|
||||
current_user: me,
|
||||
feature_mod: Plausible.Billing.Feature.Funnels
|
||||
@ -56,11 +56,11 @@ defmodule PlausibleWeb.Components.BillingTest do
|
||||
assert rendered == ""
|
||||
end
|
||||
|
||||
test "limit_exceeded_notice/1 when billable user is on growth displays upgrade link" do
|
||||
test "limit_exceeded/1 when billable user is on growth displays upgrade link" do
|
||||
me = insert(:user, subscription: build(:growth_subscription))
|
||||
|
||||
rendered =
|
||||
render_component(&Billing.limit_exceeded_notice/1,
|
||||
render_component(&Notice.limit_exceeded/1,
|
||||
billable_user: me,
|
||||
current_user: me,
|
||||
limit: 10,
|
||||
@ -72,11 +72,11 @@ defmodule PlausibleWeb.Components.BillingTest do
|
||||
assert rendered =~ "/billing/choose-plan"
|
||||
end
|
||||
|
||||
test "limit_exceeded_notice/1 when billable user is on growth but is not current user does not display upgrade link" do
|
||||
test "limit_exceeded/1 when billable user is on growth but is not current user does not display upgrade link" do
|
||||
me = insert(:user, subscription: build(:growth_subscription))
|
||||
|
||||
rendered =
|
||||
render_component(&Billing.limit_exceeded_notice/1,
|
||||
render_component(&Notice.limit_exceeded/1,
|
||||
billable_user: me,
|
||||
current_user: insert(:user),
|
||||
limit: 10,
|
||||
@ -88,11 +88,11 @@ defmodule PlausibleWeb.Components.BillingTest do
|
||||
end
|
||||
|
||||
@tag :full_build_only
|
||||
test "limit_exceeded_notice/1 when billable user is on trial displays upgrade link" do
|
||||
test "limit_exceeded/1 when billable user is on trial displays upgrade link" do
|
||||
me = insert(:user)
|
||||
|
||||
rendered =
|
||||
render_component(&Billing.limit_exceeded_notice/1,
|
||||
render_component(&Notice.limit_exceeded/1,
|
||||
billable_user: me,
|
||||
current_user: me,
|
||||
limit: 10,
|
||||
@ -104,7 +104,7 @@ defmodule PlausibleWeb.Components.BillingTest do
|
||||
assert rendered =~ "/billing/choose-plan"
|
||||
end
|
||||
|
||||
test "limit_exceeded_notice/1 when billable user is on an enterprise plan displays support email" do
|
||||
test "limit_exceeded/1 when billable user is on an enterprise plan displays support email" do
|
||||
me =
|
||||
insert(:user,
|
||||
enterprise_plan: build(:enterprise_plan, paddle_plan_id: "123321"),
|
||||
@ -112,7 +112,7 @@ defmodule PlausibleWeb.Components.BillingTest do
|
||||
)
|
||||
|
||||
rendered =
|
||||
render_component(&Billing.limit_exceeded_notice/1,
|
||||
render_component(&Notice.limit_exceeded/1,
|
||||
billable_user: me,
|
||||
current_user: me,
|
||||
limit: 10,
|
||||
@ -123,11 +123,11 @@ defmodule PlausibleWeb.Components.BillingTest do
|
||||
assert rendered =~ "please contact hello@plausible.io to upgrade your subscription"
|
||||
end
|
||||
|
||||
test "limit_exceeded_notice/1 when billable user is on a business plan displays support email" do
|
||||
test "limit_exceeded/1 when billable user is on a business plan displays support email" do
|
||||
me = insert(:user, subscription: build(:business_subscription))
|
||||
|
||||
rendered =
|
||||
render_component(&Billing.limit_exceeded_notice/1,
|
||||
render_component(&Notice.limit_exceeded/1,
|
||||
billable_user: me,
|
||||
current_user: me,
|
||||
limit: 10,
|
Loading…
Reference in New Issue
Block a user