| defmodule PlausibleWeb.Components.Billing.PlanBox do |
| @moduledoc false |
|
|
| use PlausibleWeb, :component |
|
|
| require Plausible.Billing.Subscription.Status |
| alias PlausibleWeb.Components.Billing.{PlanBenefits} |
| alias Plausible.Billing.{Plan, Quota, Subscription} |
|
|
| @plan_box_price_container_class "relative h-20 pt-4 max-h-20 whitespace-nowrap overflow-hidden" |
|
|
| def standard(assigns) do |
| highlight = |
| cond do |
| assigns.owned && assigns.recommended -> "Current" |
| assigns.recommended -> "Recommended" |
| true -> nil |
| end |
|
|
| assigns = |
| assigns |
| |> assign(:highlight, highlight) |
| |> assign(:price_container_class, @plan_box_price_container_class) |
|
|
| ~H""" |
| <div |
| id={" |
| class={[ |
| "shadow-lg border border-gray-200 dark:border-none bg-white rounded-xl px-6 sm:px-4 py-4 sm:py-3 dark:bg-gray-850", |
| !@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> |
| <.highlight_pill :if={@highlight} text={@highlight} /> |
| </div> |
| <div> |
| <div class={@price_container_class}> |
| <.render_price_info available={@available} {assigns} /> |
| </div> |
| <%= if @available do %> |
| <.checkout id={"#{@kind}-checkout"} {assigns} /> |
| <% else %> |
| <.contact_button class="bg-indigo-600 hover:bg-indigo-500 text-white" /> |
| <% end %> |
| </div> |
| <PlanBenefits.render benefits={@benefits} class="text-gray-600 dark:text-gray-100" /> |
| </div> |
| """ |
| end |
| |
| def enterprise(assigns) do |
| assigns = assign(assigns, :price_container_class, @plan_box_price_container_class) |
| |
| ~H""" |
| <div |
| id="enterprise-plan-box" |
| class={[ |
| "rounded-xl px-6 sm:px-4 py-4 sm:py-3 bg-gray-900 shadow-xl dark:bg-gray-850", |
| !@recommended && "dark:ring-gray-600", |
| @recommended && "ring-4 ring-indigo-500 dark:ring-2 dark:ring-indigo-300" |
| ]} |
| > |
| <div class="flex items-center justify-between gap-x-4"> |
| <h3 class={[ |
| "text-lg font-semibold leading-8", |
| !@recommended && "text-white dark:text-gray-100", |
| @recommended && "text-indigo-400 dark:text-indigo-300" |
| ]}> |
| Enterprise |
| </h3> |
| <span |
| :if={@recommended} |
| id="enterprise-highlight-pill" |
| class="rounded-full ring-1 ring-indigo-500 px-2.5 py-1 text-xs font-semibold leading-5 text-indigo-400 dark:text-indigo-300 dark:ring-1 dark:ring-indigo-300/50" |
| > |
| Recommended |
| </span> |
| </div> |
| <div class={@price_container_class}> |
| <span class="text-3xl lg:text-2xl xl:text-3xl font-bold tracking-tight text-white dark:text-gray-100"> |
| Custom |
| </span> |
| </div> |
| <.contact_button class="" /> |
| <PlanBenefits.render benefits={@benefits} class="text-gray-300 dark:text-gray-100" /> |
| </div> |
| """ |
| end |
| |
| defp highlight_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="flex items-baseline gap-x-1"> |
| <span class="text-3xl 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""" |
| <.price_tag kind={@kind} selected_interval={@selected_interval} plan_to_render={@plan_to_render} /> |
| <div |
| id={"#{@kind}-vat-notice"} |
| class="absolute top-[32px] sm:top-[29px] md:top-[32px] lg:top-[29px] xl:top-[32px] right-0 text-xs text-gray-500" |
| > |
| + VAT |
| <span class="hidden sm:inline lg:hidden xl:inline"> |
| if applicable |
| </span> |
| </div> |
| """ |
| end |
| |
| defp price_tag(%{selected_interval: :monthly} = assigns) do |
| monthly_cost = |
| case assigns.plan_to_render do |
| %{monthly_cost: nil} -> "N/A" |
| %{monthly_cost: monthly_cost} -> Plausible.Billing.format_price(monthly_cost) |
| end |
| |
| assigns = assign(assigns, :monthly_cost, monthly_cost) |
| |
| ~H""" |
| <p class="flex items-baseline gap-x-1"> |
| <span |
| id={"#{@kind}-price-tag-amount"} |
| class="text-3xl lg:text-2xl xl:text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-100" |
| > |
| {@monthly_cost} |
| </span> |
| <span |
| id={"#{@kind}-price-tag-interval"} |
| class="text-sm font-semibold leading-6 text-gray-600 dark:text-gray-500" |
| > |
| /month |
| </span> |
| </p> |
| """ |
| end |
| |
| defp price_tag(%{selected_interval: :yearly} = assigns) do |
| monthly_cost = |
| case assigns.plan_to_render do |
| %{monthly_cost: nil} -> "N/A" |
| %{monthly_cost: monthly_cost} -> Plausible.Billing.format_price(monthly_cost) |
| end |
| |
| {yearly_cost, monthly_cost_with_discount} = |
| case assigns.plan_to_render do |
| %{yearly_cost: nil} -> |
| {"N/A", "N/A"} |
| |
| %{yearly_cost: yearly_cost} -> |
| { |
| Plausible.Billing.format_price(yearly_cost), |
| Plausible.Billing.format_price(Money.div!(yearly_cost, 12)) |
| } |
| end |
| |
| assigns = |
| assigns |
| |> assign(:monthly_cost, monthly_cost) |
| |> assign(:yearly_cost, yearly_cost) |
| |> assign(:monthly_cost_with_discount, monthly_cost_with_discount) |
| |
| ~H""" |
| <div class="grid grid-cols-[max-content_1fr]"> |
| <span |
| id={"#{@kind}-price-tag-amount"} |
| class="text-3xl lg:text-2xl xl:text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-100" |
| > |
| {@yearly_cost} |
| </span> |
|
|
| <span |
| id={"#{@kind}-price-tag-interval"} |
| class="text-sm font-semibold leading-6 text-gray-600 dark:text-gray-500 pl-1 self-end" |
| > |
| /year |
| </span> |
|
|
| <div class="font-bold tracking-tight text-sm self-center"> |
| <span |
| id={"#{@kind}-discount-price-tag-strikethrough-amount"} |
| class="line-through tracking-tight text-gray-500" |
| > |
| {@monthly_cost} |
| </span> |
| <span id={"#{@kind}-discount-price-tag-amount"} class="ml-1 text-gray-900 dark:text-gray-100"> |
| {@monthly_cost_with_discount} |
| </span> |
| </div> |
|
|
| <span class="text-sm font-semibold text-gray-600 dark:text-gray-500 pl-1 self-center"> |
| /month |
| </span> |
| </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) |
| |
| subscription = |
| Plausible.Teams.Billing.get_subscription(assigns.current_team) |
| |
| billing_details_expired = |
| Subscription.Status.in?(subscription, [ |
| Subscription.Status.paused(), |
| Subscription.Status.past_due() |
| ]) |
| |
| subscription_deleted = Subscription.Status.deleted?(subscription) |
| usage_check = check_usage_within_plan_limits(assigns) |
| |
| {checkout_disabled, disabled_message} = |
| cond do |
| not Quota.eligible_for_upgrade?(assigns.usage) -> |
| {true, nil} |
| |
| change_plan_link_text == "Currently on this plan" && not subscription_deleted -> |
| {true, nil} |
| |
| usage_check != :ok -> |
| {true, "Your usage exceeds this plan"} |
| |
| billing_details_expired -> |
| {true, "Please update your billing details first"} |
| |
| true -> |
| {false, nil} |
| end |
| |
| exceeded_plan_limits = |
| case usage_check do |
| {:error, {:over_plan_limits, limits}} -> |
| limits |
| |
| _ -> |
| [] |
| end |
| |
| feature_usage_check = Quota.ensure_feature_access(assigns.usage, assigns.plan_to_render) |
| |
| 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(:exceeded_plan_limits, exceeded_plan_limits) |
| |> assign(:confirm_message, losing_features_message(feature_usage_check)) |
| |
| ~H""" |
| <%= if @owned_plan && Plausible.Billing.Subscriptions.resumable?(@current_team.subscription) do %> |
| <.change_plan_link {assigns} /> |
| <% else %> |
| <PlausibleWeb.Components.Billing.paddle_button |
| user={@current_user} |
| team={@current_team} |
| {assigns} |
| > |
| Upgrade |
| </PlausibleWeb.Components.Billing.paddle_button> |
| <% end %> |
| <div :if={@exceeded_plan_limits != [] && @disabled_message} class="flex flex-col items-center"> |
| <.tooltip testid="plan-tooltip"> |
| <div class="h-0 text-sm"> |
| <div class="flex items-center text-red-700 dark:text-red-500 justify-center"> |
| {@disabled_message} |
| <Heroicons.information_circle class="hidden sm:block w-5 h-5 sm:ml-2" /> |
| </div> |
| </div> |
| <:tooltip_content> |
| Your usage exceeds the following limit(s):<br /><br /> |
| <p :for={limit <- @exceeded_plan_limits}> |
| {Phoenix.Naming.humanize(limit)}<br /> |
| </p> |
| </:tooltip_content> |
| </.tooltip> |
| </div> |
| <div |
| :if={@disabled_message && @exceeded_plan_limits == []} |
| class="pt-2 text-sm w-full text-red-700 dark:text-red-500 text-center" |
| > |
| {@disabled_message} |
| </div> |
| """ |
| end |
| |
| defp check_usage_within_plan_limits(%{available: false}) do |
| {:error, :plan_unavailable} |
| end |
| |
| defp check_usage_within_plan_limits(%{ |
| available: true, |
| usage: usage, |
| current_team: current_team, |
| plan_to_render: plan |
| }) do |
| # At this point, the user is *not guaranteed* to have a team, |
| # with ongoing trial. |
| trial_active_or_ended_recently? = |
| not is_nil(current_team) and not is_nil(current_team.trial_expiry_date) and |
| Plausible.Teams.trial_days_left(current_team) >= -10 |
| |
| limit_checking_opts = |
| cond do |
| current_team && current_team.allow_next_upgrade_override -> |
| [skip_pageview_limit_check?: true] |
| |
| trial_active_or_ended_recently? && plan.volume == "10k" -> |
| [pageview_allowance_margin: 0.3] |
| |
| trial_active_or_ended_recently? -> |
| [pageview_allowance_margin: 0.15] |
| |
| true -> |
| [] |
| end |
| |
| Quota.ensure_within_plan_limits(usage, plan, limit_checking_opts) |
| 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 in [:growth, :business] && to_kind == :starter -> |
| "Downgrade to Starter" |
| |
| from_kind == :business && to_kind == :growth -> |
| "Downgrade to Growth" |
| |
| from_kind == :starter && to_kind == :growth -> |
| "Upgrade to Growth" |
| |
| from_kind in [:starter, :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(:ok), do: nil |
| |
| defp losing_features_message({:error, {:unavailable_features, features}}) do |
| features_list_str = |
| features |
| |> Enum.map(fn feature_mod -> feature_mod.display_name() end) |
| |> PlausibleWeb.TextHelpers.pretty_join() |
| |
| "This plan does not support #{features_list_str}, which you have been using. By subscribing to this plan, you will not have access to #{if length(features) == 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 |
| |