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:
RobertJoonas 2023-12-15 16:59:16 +00:00 committed by GitHub
parent 77ca5abbc8
commit 22ecbe7bc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 907 additions and 872 deletions

View File

@ -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

View File

@ -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"> &rarr;</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"> &rarr;</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()}),

View 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"> &rarr;</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"> &rarr;</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

View 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

View 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

View 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

View File

@ -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

View File

@ -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}

View File

@ -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}

View File

@ -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>

View File

@ -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"
/>

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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

View File

@ -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,