Customer support (#5390)
* Add profile_url helper clause * Add notes * Sort features alphabetically * Fix checkbox/textarea components * Unrlelated: update combobox docs * Initial customer support UI * Unrelated: don't expand member dropdown if disabled * Cross link both CRMs * Remove unused things * Stop polluting history with tab navigation * Truncate search results * Format * Use routes in favour of phx-click events * Fix / keypress to search focus * Rename phx event * Rename remaining save events * Fix up x-data * Fix alpine placeholder event * Enable progress animation with topbar * Team: separate assign clauses per tab * Site: separate assign clauses per tab * lint * Replace URI patch on filter text update * Unifyu filter_bar component usage * !fixup * Fix up filter form event name * Fix number formatting as you type * Fix enterprise plan number inputs * Link CS from HelpScout * Remove target=_blank from kaffy URLs * Pre-fill custom plans * Rework the billing tab * Make checkbox labels clickable * Put Stats API first * Format * Credo * !fixup * Don't show empty labels
This commit is contained in:
parent
96abac2d4e
commit
c009b92fca
|
|
@ -6,6 +6,7 @@
|
|||
import 'phoenix_html'
|
||||
import { Socket } from 'phoenix'
|
||||
import { LiveSocket } from 'phoenix_live_view'
|
||||
import topbar from 'topbar'
|
||||
/* eslint-enable import/no-unresolved */
|
||||
|
||||
import Alpine from 'alpinejs'
|
||||
|
|
@ -66,6 +67,14 @@ if (csrfToken && websocketUrl) {
|
|||
}
|
||||
})
|
||||
|
||||
topbar.config({
|
||||
barColors: { 0: '#303f9f' },
|
||||
shadowColor: 'rgba(0, 0, 0, .3)',
|
||||
barThickness: 4
|
||||
})
|
||||
window.addEventListener('phx:page-loading-start', (_info) => topbar.show())
|
||||
window.addEventListener('phx:page-loading-stop', (_info) => topbar.hide())
|
||||
|
||||
liveSocket.connect()
|
||||
window.liveSocket = liveSocket
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
"react-popper": "^2.3.0",
|
||||
"react-router-dom": "^6.25.1",
|
||||
"react-transition-group": "^4.4.2",
|
||||
"topbar": "^3.0.0",
|
||||
"topojson-client": "^3.1.0",
|
||||
"url-search-params-polyfill": "^8.2.5",
|
||||
"visionscarto-world-atlas": "^1.0.0"
|
||||
|
|
@ -10263,6 +10264,12 @@
|
|||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/topbar": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/topbar/-/topbar-3.0.0.tgz",
|
||||
"integrity": "sha512-mhczD7KfYi1anfoMPKRdl0wPSWiYc0YOK4KyycYs3EaNT15pVVNDG5CtfgZcEBWIPJEdfR7r8K4hTXDD2ECBVQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/topojson-client": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@
|
|||
"react-popper": "^2.3.0",
|
||||
"react-router-dom": "^6.25.1",
|
||||
"react-transition-group": "^4.4.2",
|
||||
"topbar": "^3.0.0",
|
||||
"topojson-client": "^3.1.0",
|
||||
"url-search-params-polyfill": "^8.2.5",
|
||||
"visionscarto-world-atlas": "^1.0.0"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
defmodule Plausible.CustomerSupport.Resource do
|
||||
@moduledoc """
|
||||
Generic behaviour for CS resources and their components
|
||||
"""
|
||||
defstruct [:id, :type, :module, :object]
|
||||
|
||||
@type schema() :: map()
|
||||
|
||||
@type t() :: %__MODULE__{
|
||||
id: pos_integer(),
|
||||
module: atom(),
|
||||
object: schema(),
|
||||
type: String.t()
|
||||
}
|
||||
|
||||
@callback search(String.t(), pos_integer()) :: list(schema())
|
||||
@callback get(pos_integer()) :: schema()
|
||||
@callback component() :: module()
|
||||
@callback type() :: String.t()
|
||||
@callback dump(schema()) :: t()
|
||||
|
||||
defmodule Component do
|
||||
@moduledoc false
|
||||
@callback render_result(assigns :: Phoenix.LiveView.Socket.assigns()) ::
|
||||
Phoenix.LiveView.Rendered.t()
|
||||
end
|
||||
|
||||
defmacro __using__(:component) do
|
||||
quote do
|
||||
use PlausibleWeb, :live_component
|
||||
alias Plausible.CustomerSupport.Resource
|
||||
import PlausibleWeb.CustomerSupport.Live.Shared
|
||||
|
||||
@behaviour Plausible.CustomerSupport.Resource.Component
|
||||
|
||||
def success(socket, msg) do
|
||||
send(socket.root_pid, {:success, msg})
|
||||
socket
|
||||
end
|
||||
|
||||
def failure(socket, msg) do
|
||||
send(socket.root_pid, {:failure, msg})
|
||||
socket
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defmacro __using__(component: component) do
|
||||
quote do
|
||||
@behaviour Plausible.CustomerSupport.Resource
|
||||
alias Plausible.CustomerSupport.Resource
|
||||
|
||||
import Ecto.Query
|
||||
alias Plausible.Repo
|
||||
|
||||
@impl true
|
||||
def dump(schema) do
|
||||
Resource.new(__MODULE__, schema)
|
||||
end
|
||||
|
||||
defoverridable dump: 1
|
||||
|
||||
@impl true
|
||||
def type do
|
||||
__MODULE__
|
||||
|> Module.split()
|
||||
|> Enum.reverse()
|
||||
|> hd()
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
defoverridable type: 0
|
||||
|
||||
@impl true
|
||||
def component, do: unquote(component)
|
||||
|
||||
defoverridable component: 0
|
||||
end
|
||||
end
|
||||
|
||||
def new(module, schema) do
|
||||
%__MODULE__{
|
||||
id: schema.id,
|
||||
type: module.type(),
|
||||
module: module,
|
||||
object: schema
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
defmodule Plausible.CustomerSupport.Resource.Site do
|
||||
@moduledoc false
|
||||
use Plausible.CustomerSupport.Resource, component: PlausibleWeb.CustomerSupport.Live.Site
|
||||
|
||||
@impl true
|
||||
def search("", limit) do
|
||||
q =
|
||||
from s in Plausible.Site,
|
||||
inner_join: t in assoc(s, :team),
|
||||
inner_join: o in assoc(t, :owners),
|
||||
order_by: [
|
||||
desc: :id
|
||||
],
|
||||
limit: ^limit,
|
||||
preload: [team: {t, owners: o}]
|
||||
|
||||
Plausible.Repo.all(q)
|
||||
end
|
||||
|
||||
def search(input, limit) do
|
||||
q =
|
||||
from s in Plausible.Site,
|
||||
inner_join: t in assoc(s, :team),
|
||||
inner_join: o in assoc(t, :owners),
|
||||
where:
|
||||
ilike(s.domain, ^"%#{input}%") or ilike(t.name, ^"%#{input}%") or
|
||||
ilike(o.name, ^"%#{input}%"),
|
||||
order_by: [
|
||||
desc: fragment("?.domain = ?", s, ^input),
|
||||
desc: fragment("?.name = ?", t, ^input),
|
||||
desc: fragment("?.name = ?", o, ^input),
|
||||
asc: s.domain
|
||||
],
|
||||
limit: ^limit,
|
||||
preload: [team: {t, owners: o}]
|
||||
|
||||
Plausible.Repo.all(q)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def get(id) do
|
||||
Plausible.Site
|
||||
|> Plausible.Repo.get(id)
|
||||
|> Plausible.Repo.preload(:team)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
defmodule Plausible.CustomerSupport.Resource.Team do
|
||||
@moduledoc false
|
||||
use Plausible.CustomerSupport.Resource, component: PlausibleWeb.CustomerSupport.Live.Team
|
||||
|
||||
@impl true
|
||||
def search("", limit) do
|
||||
q =
|
||||
from t in Plausible.Teams.Team,
|
||||
inner_join: o in assoc(t, :owners),
|
||||
limit: ^limit,
|
||||
where: not is_nil(t.trial_expiry_date),
|
||||
order_by: [desc: :id],
|
||||
preload: [owners: o]
|
||||
|
||||
Plausible.Repo.all(q)
|
||||
end
|
||||
|
||||
def search(input, limit) do
|
||||
q =
|
||||
from t in Plausible.Teams.Team,
|
||||
inner_join: o in assoc(t, :owners),
|
||||
where: ilike(t.name, ^"%#{input}%") or ilike(o.name, ^"%#{input}%"),
|
||||
limit: ^limit,
|
||||
order_by: [
|
||||
desc: fragment("?.name = ?", t, ^input),
|
||||
desc: fragment("?.name = ?", o, ^input),
|
||||
asc: t.name
|
||||
],
|
||||
preload: [owners: o]
|
||||
|
||||
Plausible.Repo.all(q)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def get(id) do
|
||||
Plausible.Teams.Team
|
||||
|> Repo.get(id)
|
||||
|> Plausible.Teams.with_subscription()
|
||||
|> Repo.preload(:owners)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
defmodule Plausible.CustomerSupport.Resource.User do
|
||||
@moduledoc false
|
||||
use Plausible.CustomerSupport.Resource, component: PlausibleWeb.CustomerSupport.Live.User
|
||||
|
||||
@impl true
|
||||
def get(id) do
|
||||
Plausible.Repo.get!(Plausible.Auth.User, id)
|
||||
|> Plausible.Repo.preload(team_memberships: :team)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def search("", limit) do
|
||||
q =
|
||||
from u in Plausible.Auth.User,
|
||||
order_by: [
|
||||
desc: :id
|
||||
],
|
||||
preload: [:owned_teams],
|
||||
limit: ^limit
|
||||
|
||||
Plausible.Repo.all(q)
|
||||
end
|
||||
|
||||
def search(input, limit) do
|
||||
q =
|
||||
from u in Plausible.Auth.User,
|
||||
where: ilike(u.email, ^"%#{input}%") or ilike(u.name, ^"%#{input}%"),
|
||||
order_by: [
|
||||
desc: fragment("?.name = ?", u, ^input),
|
||||
desc: fragment("?.email = ?", u, ^input),
|
||||
asc: u.name
|
||||
],
|
||||
preload: [:owned_teams],
|
||||
limit: ^limit
|
||||
|
||||
Plausible.Repo.all(q)
|
||||
end
|
||||
end
|
||||
|
|
@ -105,7 +105,14 @@ defmodule Plausible.HelpScout do
|
|||
}
|
||||
end)
|
||||
|
||||
user_link = Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :auth, :user, user.id)
|
||||
user_link =
|
||||
Routes.customer_support_resource_url(
|
||||
PlausibleWeb.Endpoint,
|
||||
:details,
|
||||
:users,
|
||||
:user,
|
||||
user.id
|
||||
)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
|
|
@ -129,19 +136,20 @@ defmodule Plausible.HelpScout do
|
|||
|
||||
status_link =
|
||||
if team do
|
||||
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :teams, :team, team.id)
|
||||
else
|
||||
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :auth, :user, user.id)
|
||||
end
|
||||
|
||||
sites_link =
|
||||
if team do
|
||||
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site,
|
||||
custom_search: team.identifier
|
||||
Routes.customer_support_resource_url(
|
||||
PlausibleWeb.Endpoint,
|
||||
:details,
|
||||
:teams,
|
||||
:team,
|
||||
team.id
|
||||
)
|
||||
else
|
||||
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site,
|
||||
custom_search: user.email
|
||||
Routes.customer_support_resource_url(
|
||||
PlausibleWeb.Endpoint,
|
||||
:details,
|
||||
:users,
|
||||
:user,
|
||||
user.id
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -156,8 +164,7 @@ defmodule Plausible.HelpScout do
|
|||
status_link: status_link,
|
||||
plan_label: plan_label(subscription, plan),
|
||||
plan_link: plan_link(subscription),
|
||||
sites_count: Teams.owned_sites_count(team),
|
||||
sites_link: sites_link
|
||||
sites_count: Teams.owned_sites_count(team)
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
defmodule PlausibleWeb.Live.CustomerSupport do
|
||||
@moduledoc """
|
||||
Customer Support UI
|
||||
"""
|
||||
use PlausibleWeb, :live_view
|
||||
alias Plausible.CustomerSupport.Resource
|
||||
|
||||
@resources [Resource.Team, Resource.User, Resource.Site]
|
||||
@resources_by_type @resources |> Enum.into(%{}, fn mod -> {mod.type(), mod} end)
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
uri =
|
||||
("/cs?" <> URI.encode_query(Map.take(params, ["filter_text"])))
|
||||
|> URI.new!()
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
resources_by_type: @resources_by_type,
|
||||
results: [],
|
||||
current: nil,
|
||||
uri: uri,
|
||||
filter_text: params["filter_text"] || ""
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.flash_messages flash={@flash} />
|
||||
|
||||
<div class="container pt-6">
|
||||
<div class="group mt-6 pb-5 border-b border-gray-200 dark:border-gray-500 flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 dark:text-gray-100 sm:text-3xl sm:leading-9 sm:truncate flex-shrink-0">
|
||||
💬 Customer Support
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 mt-4">
|
||||
<.filter_bar filter_text={@filter_text} placeholder="Search everything"></.filter_bar>
|
||||
</div>
|
||||
|
||||
<ul :if={!@current} class="my-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<li :for={r <- @results} class="group relative">
|
||||
<.link patch={"/cs/#{r.type}s/#{r.type}/#{r.id}"}>
|
||||
<div class="col-span-1 bg-white dark:bg-gray-800 rounded-lg shadow p-4 group-hover:shadow-lg cursor-pointer">
|
||||
<div class="text-gray-800 dark:text-gray-500 w-full flex items-center justify-between space-x-4">
|
||||
<.render_result resource={r} />
|
||||
</div>
|
||||
</div>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
id="modal"
|
||||
class={[
|
||||
if(is_nil(@current), do: "hidden")
|
||||
]}
|
||||
>
|
||||
<div class="overflow-auto bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-300 w-full h-3/4 max-w-7xl max-h-full p-4 rounded-lg shadow-lg">
|
||||
<div class="flex justify-between text-xs">
|
||||
<.styled_link onclick="window.history.go(-1); return false;">
|
||||
← Previous
|
||||
</.styled_link>
|
||||
<.styled_link :if={@current} class="text-xs" href={kaffy_url(@current, @id)}>
|
||||
open in Kaffy
|
||||
</.styled_link>
|
||||
</div>
|
||||
<.live_component
|
||||
:if={@current}
|
||||
module={@current.component()}
|
||||
resource_id={@id}
|
||||
id={"#{@current.type()}-#{@id}"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def render_result(assigns) do
|
||||
apply(assigns.resource.module.component(), :render_result, [assigns])
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id, "resource" => type} = p, _uri, socket) do
|
||||
mod = Map.fetch!(@resources_by_type, type)
|
||||
|
||||
id = String.to_integer(id)
|
||||
|
||||
send_update(self(), mod.component(),
|
||||
id: "#{mod.type()}-#{id}",
|
||||
tab: p["tab"]
|
||||
)
|
||||
|
||||
{:noreply, assign(socket, type: type, current: mod, id: id)}
|
||||
end
|
||||
|
||||
def handle_params(%{"filter_text" => _}, _uri, socket) do
|
||||
socket =
|
||||
search(assign(socket, current: nil))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_params(_, _uri, socket) do
|
||||
{:noreply, search(socket)}
|
||||
end
|
||||
|
||||
def search(%{assigns: assigns} = socket) do
|
||||
results = spawn_searches(assigns.filter_text)
|
||||
assign(socket, results: results)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("filter", %{"filter-text" => input}, socket) do
|
||||
socket = set_filter_text(socket, input)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("reset-filter-text", _params, socket) do
|
||||
socket = set_filter_text(socket, "")
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("close", _, socket) do
|
||||
{:noreply, assign(socket, current: nil)}
|
||||
end
|
||||
|
||||
def handle_info({:success, msg}, socket) do
|
||||
{:noreply, put_live_flash(socket, :success, msg)}
|
||||
end
|
||||
|
||||
def handle_info({:failure, msg}, socket) do
|
||||
{:noreply, put_live_flash(socket, :error, msg)}
|
||||
end
|
||||
|
||||
defp spawn_searches(input) do
|
||||
input = String.trim(input)
|
||||
|
||||
{resources, input, limit} =
|
||||
maybe_focus_search(input)
|
||||
|
||||
resources
|
||||
|> Task.async_stream(fn resource ->
|
||||
input
|
||||
|> resource.search(limit)
|
||||
|> Enum.map(&resource.dump/1)
|
||||
end)
|
||||
|> Enum.reduce([], fn {:ok, results}, acc ->
|
||||
acc ++ results
|
||||
end)
|
||||
end
|
||||
|
||||
defp maybe_focus_search(lone_modifier) when lone_modifier in ["site:", "team:", "user:"] do
|
||||
{[], "", 0}
|
||||
end
|
||||
|
||||
defp maybe_focus_search("site:" <> rest) do
|
||||
{[Resource.Site], rest, 90}
|
||||
end
|
||||
|
||||
defp maybe_focus_search("team:" <> rest) do
|
||||
{[Resource.Team], rest, 90}
|
||||
end
|
||||
|
||||
defp maybe_focus_search("user:" <> rest) do
|
||||
{[Resource.User], rest, 90}
|
||||
end
|
||||
|
||||
defp maybe_focus_search(input) do
|
||||
{@resources, input, 30}
|
||||
end
|
||||
|
||||
defp set_filter_text(socket, filter_text) do
|
||||
uri = socket.assigns.uri
|
||||
|
||||
uri_params =
|
||||
uri.query
|
||||
|> URI.decode_query()
|
||||
|> Map.put("filter_text", filter_text)
|
||||
|> URI.encode_query()
|
||||
|
||||
uri = %{uri | query: uri_params}
|
||||
|
||||
socket
|
||||
|> assign(:filter_text, filter_text)
|
||||
|> assign(:uri, uri)
|
||||
|> push_patch(to: URI.to_string(uri), replace: true)
|
||||
end
|
||||
|
||||
defp kaffy_url(nil, _id), do: ""
|
||||
|
||||
defp kaffy_url(current, id) do
|
||||
r =
|
||||
current.type()
|
||||
|
||||
kaffy_r =
|
||||
case r do
|
||||
"user" -> "auth"
|
||||
"team" -> "teams"
|
||||
"site" -> "sites"
|
||||
end
|
||||
|
||||
"/crm/#{kaffy_r}/#{r}/#{id}"
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
defmodule PlausibleWeb.CustomerSupport.Live.Shared do
|
||||
@moduledoc false
|
||||
use Phoenix.Component
|
||||
|
||||
attr :to, :string, required: true
|
||||
attr :tab, :string, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
def tab(assigns) do
|
||||
~H"""
|
||||
<.link
|
||||
replace
|
||||
patch={"?tab=#{@to}"}
|
||||
class="group relative min-w-0 flex-1 overflow-hidden rounded-l-lg px-4 py-4 text-center text-sm font-medium focus:z-10 cursor-pointer text-gray-800 dark:text-gray-200"
|
||||
>
|
||||
<span class={if(@tab == @to, do: "font-bold")}>
|
||||
{render_slot(@inner_block)}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class={[
|
||||
"absolute inset-x-0 bottom-0 h-0.5",
|
||||
if(@tab == @to, do: "dark:bg-indigo-300 bg-indigo-500", else: "bg-transparent")
|
||||
]}
|
||||
>
|
||||
</span>
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
defmodule PlausibleWeb.CustomerSupport.Live.Site do
|
||||
@moduledoc false
|
||||
use Plausible.CustomerSupport.Resource, :component
|
||||
|
||||
def update(%{resource_id: resource_id}, socket) do
|
||||
site = Resource.Site.get(resource_id)
|
||||
changeset = Plausible.Site.crm_changeset(site, %{})
|
||||
form = to_form(changeset)
|
||||
{:ok, assign(socket, site: site, form: form)}
|
||||
end
|
||||
|
||||
def update(%{tab: "people"}, %{assigns: %{site: site}} = socket) do
|
||||
people = Plausible.Sites.list_people(site)
|
||||
|
||||
people =
|
||||
(people.invitations ++ people.memberships)
|
||||
|> Enum.map(fn p ->
|
||||
if Map.has_key?(p, :invitation_id) do
|
||||
{:invitation, p.email, p.role}
|
||||
else
|
||||
{:membership, p.user, p.role}
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, assign(socket, people: people, tab: "people")}
|
||||
end
|
||||
|
||||
def update(_, socket) do
|
||||
{:ok, assign(socket, tab: "overview")}
|
||||
end
|
||||
|
||||
attr :tab, :string, default: "overview"
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-full p-1 mr-4">
|
||||
<.favicon class="w-8" domain={@site.domain} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xl font-bold sm:text-2xl">
|
||||
{@site.domain}
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
Timezone: {@site.timezone}
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
Team:
|
||||
<.styled_link patch={"/cs/teams/team/#{@site.team.id}"}>
|
||||
{@site.team.name}
|
||||
</.styled_link>
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
<span :if={@site.domain_changed_from}>(previously: {@site.domain_changed_from})</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="hidden sm:block">
|
||||
<nav
|
||||
class="isolate flex divide-x dark:divide-gray-900 divide-gray-200 rounded-lg shadow dark:shadow-1"
|
||||
aria-label="Tabs"
|
||||
>
|
||||
<.tab to="overview" tab={@tab}>Overview</.tab>
|
||||
<.tab to="people" tab={@tab}>
|
||||
People
|
||||
</.tab>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
:let={f}
|
||||
:if={@tab == "overview"}
|
||||
for={@form}
|
||||
phx-target={@myself}
|
||||
phx-submit="save-site"
|
||||
class="mt-8"
|
||||
>
|
||||
<.input
|
||||
type="select"
|
||||
field={f[:timezone]}
|
||||
label="Timezone"
|
||||
options={Plausible.Timezones.options()}
|
||||
/>
|
||||
<.input type="checkbox" field={f[:public]} label="Public?" />
|
||||
<.input type="datetime-local" field={f[:native_stats_start_at]} label="Native Stats Start At" />
|
||||
<.input
|
||||
type="text"
|
||||
field={f[:ingest_rate_limit_threshold]}
|
||||
label="Ingest Rate Limit Threshold"
|
||||
/>
|
||||
<.input
|
||||
type="text"
|
||||
field={f[:ingest_rate_limit_scale_seconds]}
|
||||
label="Ingest Rate Limit Scale Seconds"
|
||||
/>
|
||||
<.button phx-target={@myself} type="submit">
|
||||
Save
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<div :if={@tab == "people"} class="mt-8">
|
||||
<.table rows={@people}>
|
||||
<:thead>
|
||||
<.th>User</.th>
|
||||
<.th>Kind</.th>
|
||||
<.th>Role</.th>
|
||||
</:thead>
|
||||
<:tbody :let={{kind, person, role}}>
|
||||
<.td :if={kind == :membership}>
|
||||
<.styled_link class="flex items-center" patch={"/cs/users/user/#{person.id}"}>
|
||||
<img
|
||||
src={Plausible.Auth.User.profile_img_url(person)}
|
||||
class="w-4 rounded-full bg-gray-300 mr-2"
|
||||
/>
|
||||
{person.name}
|
||||
</.styled_link>
|
||||
</.td>
|
||||
|
||||
<.td :if={kind == :invitation}>
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
src={Plausible.Auth.User.profile_img_url(person)}
|
||||
class="w-4 rounded-full bg-gray-300 mr-2"
|
||||
/>
|
||||
{person}
|
||||
</div>
|
||||
</.td>
|
||||
|
||||
<.td :if={kind == :membership}>
|
||||
Membership
|
||||
</.td>
|
||||
|
||||
<.td :if={kind == :invitation}>
|
||||
Invitation
|
||||
</.td>
|
||||
<.td>{role}</.td>
|
||||
</:tbody>
|
||||
</.table>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def render_result(assigns) do
|
||||
~H"""
|
||||
<div class="flex-1 -mt-px w-full">
|
||||
<div class="w-full flex items-center justify-between space-x-4">
|
||||
<.favicon class="w-5" domain={@resource.object.domain} />
|
||||
<h3
|
||||
class="text-gray-900 font-medium text-lg truncate dark:text-gray-100"
|
||||
style="width: calc(100% - 4rem)"
|
||||
>
|
||||
{@resource.object.domain}
|
||||
</h3>
|
||||
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
Site
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr class="mt-4 mb-4 flex-grow border-t border-gray-200 dark:border-gray-600" />
|
||||
<div class="text-sm truncate">
|
||||
Part of <strong>{@resource.object.team.name}</strong>
|
||||
<br />
|
||||
owned by {@resource.object.team.owners
|
||||
|> Enum.map(& &1.name)
|
||||
|> Enum.join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :domain, :string, required: true
|
||||
attr :class, :string, required: true
|
||||
|
||||
def favicon(assigns) do
|
||||
~H"""
|
||||
<img src={"/favicon/sources/#{@domain}"} class={@class} />
|
||||
"""
|
||||
end
|
||||
|
||||
def handle_event("save-site", %{"site" => params}, socket) do
|
||||
changeset = Plausible.Site.crm_changeset(socket.assigns.site, params)
|
||||
|
||||
case Plausible.Repo.update(changeset) do
|
||||
{:ok, site} ->
|
||||
success(socket, "Site saved")
|
||||
{:noreply, assign(socket, site: site, form: to_form(changeset))}
|
||||
|
||||
{:error, changeset} ->
|
||||
failure(socket, "Error saving site: #{inspect(changeset.errors)}")
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,713 @@
|
|||
defmodule PlausibleWeb.CustomerSupport.Live.Team do
|
||||
@moduledoc false
|
||||
use Plausible.CustomerSupport.Resource, :component
|
||||
|
||||
alias Plausible.Billing.{Plans, Subscription}
|
||||
alias Plausible.Teams
|
||||
alias Plausible.Teams.Management.Layout
|
||||
alias Plausible.Billing.EnterprisePlan
|
||||
|
||||
alias PlausibleWeb.Router.Helpers, as: Routes
|
||||
|
||||
alias Plausible.Repo
|
||||
import Ecto.Query
|
||||
|
||||
def update(%{resource_id: resource_id}, socket) do
|
||||
team = Resource.Team.get(resource_id)
|
||||
changeset = Plausible.Teams.Team.crm_changeset(team, %{})
|
||||
form = to_form(changeset)
|
||||
|
||||
usage = Teams.Billing.quota_usage(team, with_features: true)
|
||||
|
||||
limits = %{
|
||||
monthly_pageviews: Teams.Billing.monthly_pageview_limit(team),
|
||||
sites: Teams.Billing.site_limit(team),
|
||||
team_members: Teams.Billing.team_member_limit(team)
|
||||
}
|
||||
|
||||
{:ok, assign(socket, team: team, form: form, usage: usage, limits: limits)}
|
||||
end
|
||||
|
||||
def update(%{tab: "sites"}, %{assigns: %{team: team}} = socket) do
|
||||
any_owner = Plausible.Repo.preload(team, [:owners]).owners |> hd()
|
||||
sites = Teams.Sites.list(any_owner, %{}, team: team)
|
||||
{:ok, assign(socket, sites: sites, tab: "sites")}
|
||||
end
|
||||
|
||||
def update(%{tab: "billing"}, %{assigns: %{team: team}} = socket) do
|
||||
plans = get_plans(team.id)
|
||||
plan = Plans.get_subscription_plan(team.subscription)
|
||||
|
||||
attrs =
|
||||
if is_map(plan) do
|
||||
Map.take(plan, [
|
||||
:billing_interval,
|
||||
:monthly_pageview_limit,
|
||||
:site_limit,
|
||||
:team_member_limit,
|
||||
:hourly_api_request_limit,
|
||||
:features
|
||||
])
|
||||
|> Map.update(:features, [], fn features ->
|
||||
Enum.map(features, &to_string(&1.name()))
|
||||
end)
|
||||
else
|
||||
%{site_limit: "10,000"}
|
||||
end
|
||||
|
||||
plan_form =
|
||||
to_form(
|
||||
EnterprisePlan.changeset(
|
||||
%EnterprisePlan{},
|
||||
attrs
|
||||
)
|
||||
)
|
||||
|
||||
{:ok,
|
||||
assign(socket,
|
||||
plans: plans,
|
||||
plan_form: plan_form,
|
||||
show_plan_form?: false,
|
||||
tab: "billing"
|
||||
)}
|
||||
end
|
||||
|
||||
def update(%{tab: "members"}, %{assigns: %{team: team}} = socket) do
|
||||
team_layout = Layout.init(team)
|
||||
{:ok, assign(socket, team_layout: team_layout, tab: "members")}
|
||||
end
|
||||
|
||||
def update(_, socket) do
|
||||
{:ok, assign(socket, tab: "overview")}
|
||||
end
|
||||
|
||||
attr :tab, :string, default: "overview"
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<script type="text/javascript">
|
||||
const numberFormatCallback = function(e) {
|
||||
const numeric = Number(e.target.value.replace(/[^0-9]/g, ''))
|
||||
const value = numeric > 0 ? new Intl.NumberFormat("en-GB").format(numeric) : ''
|
||||
e.target.value = value
|
||||
}
|
||||
</script>
|
||||
<div class="overflow-hidden rounded-lg">
|
||||
<div class="p-6">
|
||||
<div class="sm:flex sm:items-center sm:justify-between">
|
||||
<div class="sm:flex sm:space-x-5">
|
||||
<div class="shrink-0">
|
||||
<div class={[
|
||||
team_bg(@team.identifier),
|
||||
"rounded-full p-1 flex items-center justify-center"
|
||||
]}>
|
||||
<Heroicons.user_group class="h-14 w-14 text-white dark:text-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 text-center sm:mt-0 sm:pt-1 sm:text-left">
|
||||
<p class="text-xl font-bold dark:text-gray-300 text-gray-900 sm:text-2xl">
|
||||
{@team.name}
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
<span :if={@team.setup_complete}>Set up at {@team.setup_at}</span>
|
||||
<span :if={!@team.setup_complete}>Not set up yet</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div :if={@team.grace_period}>
|
||||
<span :if={@team.locked} class="flex items-center">
|
||||
<Heroicons.lock_closed solid class="inline stroke-2 w-4 h-4 text-red-400 mr-2" />
|
||||
<.styled_link phx-click="unlock" phx-target={@myself}>Unlock Team</.styled_link>
|
||||
</span>
|
||||
|
||||
<span :if={!@team.locked} class="flex items-center">
|
||||
<Heroicons.lock_open class="inline stroke-2 w-4 h-4 text-gray-800 mr-2" />
|
||||
<.styled_link phx-click="lock" phx-target={@myself}>Lock Team</.styled_link>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-5 flex justify-center sm:mt-0">
|
||||
<.input_with_clipboard
|
||||
id="team-identifier"
|
||||
name="team-identifier"
|
||||
label="Team Identifier"
|
||||
value={@team.identifier}
|
||||
onfocus="this.value = this.value;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="hidden sm:block">
|
||||
<nav
|
||||
class="isolate flex divide-x dark:divide-gray-900 divide-gray-200 rounded-lg shadow dark:shadow-none"
|
||||
aria-label="Tabs"
|
||||
>
|
||||
<.tab to="overview" tab={@tab}>Overview</.tab>
|
||||
<.tab to="members" tab={@tab}>
|
||||
Members ({number_format(@usage.team_members)}/{number_format(@limits.team_members)})
|
||||
</.tab>
|
||||
<.tab to="sites" tab={@tab}>
|
||||
Sites ({number_format(@usage.sites)}/{number_format(@limits.sites)})
|
||||
</.tab>
|
||||
<.tab to="billing" tab={@tab}>
|
||||
Billing
|
||||
</.tab>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 divide-y border-t sm:grid-cols-3 sm:divide-x sm:divide-y-0 dark:bg-gray-850 text-gray-900 dark:text-gray-400 dark:divide-gray-800 dark:border-gray-600">
|
||||
<div class="px-6 py-5 text-center text-sm font-medium">
|
||||
<span>
|
||||
<strong>Subscription status</strong> <br />{subscription_status(@team)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-6 py-5 text-center text-sm font-medium">
|
||||
<span>
|
||||
<strong>Subscription plan</strong> <br />{subscription_plan(@team)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-6 py-5 text-center text-sm font-medium">
|
||||
<span>
|
||||
<strong>Grace Period</strong> <br />{grace_period_status(@team)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :if={@tab == "billing"} class="mt-4 mb-4 text-gray-900 dark:text-gray-400">
|
||||
<h1 class="text-xs font-semibold">Usage</h1>
|
||||
<.table rows={monthly_pageviews_usage(@usage.monthly_pageviews, @limits.monthly_pageviews)}>
|
||||
<:thead>
|
||||
<.th invisible>Cycle</.th>
|
||||
<.th invisible>Dates</.th>
|
||||
<.th>Total</.th>
|
||||
<.th>Limit</.th>
|
||||
</:thead>
|
||||
<:tbody :let={{cycle, date, total, limit}}>
|
||||
<.td>{cycle}</.td>
|
||||
<.td>{date}</.td>
|
||||
<.td>
|
||||
<span class={if total > limit, do: "text-red-600"}>{number_format(total)}</span>
|
||||
</.td>
|
||||
<.td>{number_format(limit)}</.td>
|
||||
</:tbody>
|
||||
</.table>
|
||||
|
||||
<p :if={@usage.features != []} class="mt-6 mb-4">
|
||||
<h1 class="text-xs font-semibold">Features Used</h1>
|
||||
<span class="text-sm">
|
||||
{@usage.features |> Enum.map(& &1.display_name()) |> Enum.join(", ")}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<h1 :if={!@show_plan_form? and @plans != []} class="mt-8 text-xs font-semibold">
|
||||
Custom Plans
|
||||
</h1>
|
||||
<.table :if={!@show_plan_form?} rows={@plans}>
|
||||
<:thead>
|
||||
<.th invisible>Interval</.th>
|
||||
<.th>Paddle Plan ID</.th>
|
||||
<.th>Limits</.th>
|
||||
<.th>Features</.th>
|
||||
</:thead>
|
||||
<:tbody :let={plan}>
|
||||
<.td class="align-top">
|
||||
{plan.billing_interval}
|
||||
</.td>
|
||||
<.td class="align-top">
|
||||
{plan.paddle_plan_id}
|
||||
|
||||
<span
|
||||
:if={
|
||||
(@team.subscription && @team.subscription.paddle_plan_id) == plan.paddle_plan_id
|
||||
}
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-xs bg-red-100 text-red-800"
|
||||
>
|
||||
CURRENT
|
||||
</span>
|
||||
</.td>
|
||||
<.td max_width="max-w-40">
|
||||
<.table rows={[
|
||||
{"Pageviews", number_format(plan.monthly_pageview_limit)},
|
||||
{"Sites", number_format(plan.site_limit)},
|
||||
{"Members", number_format(plan.team_member_limit)},
|
||||
{"API Requests", number_format(plan.hourly_api_request_limit)}
|
||||
]}>
|
||||
<:tbody :let={{label, value}}>
|
||||
<.td>{label}</.td>
|
||||
<.td>{value}</.td>
|
||||
</:tbody>
|
||||
</.table>
|
||||
</.td>
|
||||
<.td class="align-top">
|
||||
<span :for={feat <- plan.features}>{feat.display_name()}<br /></span>
|
||||
</.td>
|
||||
</:tbody>
|
||||
</.table>
|
||||
|
||||
<.form
|
||||
:let={f}
|
||||
:if={@show_plan_form?}
|
||||
for={@plan_form}
|
||||
phx-submit="save-plan"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.input field={f[:paddle_plan_id]} label="Paddle Plan ID" autocomplete="off" />
|
||||
<.input
|
||||
type="select"
|
||||
options={["monthly", "yearly"]}
|
||||
field={f[:billing_interval]}
|
||||
label="Billing Interval"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<.input
|
||||
x-on:input="numberFormatCallback(event)"
|
||||
field={f[:monthly_pageview_limit]}
|
||||
label="Monthly Pageview Limit"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<.input
|
||||
x-on:input="numberFormatCallback(event)"
|
||||
field={f[:site_limit]}
|
||||
label="Site Limit"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<.input
|
||||
x-on:input="numberFormatCallback(event)"
|
||||
field={f[:team_member_limit]}
|
||||
label="Team Member Limit"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<.input
|
||||
x-on:input="numberFormatCallback(event)"
|
||||
field={f[:hourly_api_request_limit]}
|
||||
label="Hourly API Request Limit"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<.input
|
||||
:for={
|
||||
mod <-
|
||||
Plausible.Billing.Feature.list()
|
||||
|> Enum.sort_by(fn item -> if item.name() == :stats_api, do: 0, else: 1 end)
|
||||
}
|
||||
:if={not mod.free?()}
|
||||
type="checkbox"
|
||||
name={"#{f.name}[features[]][]"}
|
||||
value={mod.name()}
|
||||
label={mod.display_name()}
|
||||
checked={mod in (f.source.changes[:features] || [])}
|
||||
/>
|
||||
|
||||
<.button type="submit">
|
||||
Save Custom Plan
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<.button :if={!@show_plan_form?} phx-click="show-plan-form" phx-target={@myself}>
|
||||
New Custom Plan
|
||||
</.button>
|
||||
<.button
|
||||
:if={@show_plan_form?}
|
||||
theme="bright"
|
||||
phx-click="hide-plan-form"
|
||||
phx-target={@myself}
|
||||
>
|
||||
Cancel
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<div :if={@tab == "overview"} class="mt-8">
|
||||
<.form :let={f} for={@form} phx-submit="save-team" phx-target={@myself}>
|
||||
<.input field={f[:trial_expiry_date]} label="Trial Expiry Date" />
|
||||
<.input field={f[:accept_traffic_until]} label="Accept traffic Until" />
|
||||
<.input
|
||||
type="checkbox"
|
||||
field={f[:allow_next_upgrade_override]}
|
||||
label="Allow Next Upgrade Override"
|
||||
/>
|
||||
|
||||
<.input type="textarea" field={f[:notes]} label="Notes" />
|
||||
<.button type="submit">
|
||||
Save
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
|
||||
<div :if={@tab == "sites"} class="mt-2">
|
||||
<.table rows={@sites.entries}>
|
||||
<:thead>
|
||||
<.th>Domain</.th>
|
||||
<.th>Previous Domain</.th>
|
||||
<.th invisible>Settings</.th>
|
||||
<.th invisible>Dashboard</.th>
|
||||
</:thead>
|
||||
<:tbody :let={site}>
|
||||
<.td>
|
||||
<div class="flex items-center">
|
||||
<img
|
||||
src="/favicon/sources/{site.domain}"
|
||||
onerror="this.onerror=null; this.src='/favicon/sources/placeholder';"
|
||||
class="w-4 h-4 flex-shrink-0 mt-px mr-2"
|
||||
/>
|
||||
<.styled_link
|
||||
patch={"/cs/sites/site/#{site.id}"}
|
||||
class="cursor-pointer flex block items-center"
|
||||
>
|
||||
{site.domain}
|
||||
</.styled_link>
|
||||
</div>
|
||||
</.td>
|
||||
<.td>{site.domain_changed_from || "--"}</.td>
|
||||
<.td>
|
||||
<.styled_link
|
||||
new_tab={true}
|
||||
href={Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain, [])}
|
||||
>
|
||||
Dashboard
|
||||
</.styled_link>
|
||||
</.td>
|
||||
<.td>
|
||||
<.styled_link
|
||||
new_tab={true}
|
||||
href={Routes.site_path(PlausibleWeb.Endpoint, :settings_general, site.domain, [])}
|
||||
>
|
||||
Settings
|
||||
</.styled_link>
|
||||
</.td>
|
||||
</:tbody>
|
||||
</.table>
|
||||
</div>
|
||||
|
||||
<div :if={@tab == "members"} class="mt-2">
|
||||
<.table rows={Layout.sorted_for_display(@team_layout)}>
|
||||
<:thead>
|
||||
<.th>User</.th>
|
||||
<.th>Type</.th>
|
||||
<.th>Role</.th>
|
||||
</:thead>
|
||||
<:tbody :let={{_, member}}>
|
||||
<.td truncate>
|
||||
<div :if={member.id != 0}>
|
||||
<.styled_link
|
||||
patch={"/cs/users/user/#{member.id}"}
|
||||
class="cursor-pointer flex block items-center"
|
||||
>
|
||||
<img
|
||||
src={
|
||||
Plausible.Auth.User.profile_img_url(%Plausible.Auth.User{email: member.email})
|
||||
}
|
||||
class="mr-4 w-6 rounded-full bg-gray-300"
|
||||
/>
|
||||
{member.name} <{member.email}>
|
||||
</.styled_link>
|
||||
</div>
|
||||
<div :if={member.id == 0} class="flex items-center">
|
||||
<img
|
||||
src={
|
||||
Plausible.Auth.User.profile_img_url(%Plausible.Auth.User{email: member.email})
|
||||
}
|
||||
class="mr-4 w-6 rounded-full bg-gray-300"
|
||||
/>
|
||||
{member.name} <{member.email}>
|
||||
</div>
|
||||
</.td>
|
||||
<.td>
|
||||
{member.type}
|
||||
</.td>
|
||||
<.td>
|
||||
{member.role}
|
||||
</.td>
|
||||
</:tbody>
|
||||
</.table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def render_result(assigns) do
|
||||
~H"""
|
||||
<div class="flex-1 -mt-px w-full">
|
||||
<div class="w-full flex items-center justify-between space-x-4">
|
||||
<div class={[
|
||||
team_bg(@resource.object.identifier),
|
||||
"rounded-full p-1 flex items-center justify-center"
|
||||
]}>
|
||||
<Heroicons.user_group class="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<h3
|
||||
class="text-gray-900 font-medium text-lg truncate dark:text-gray-100"
|
||||
style="width: calc(100% - 4rem)"
|
||||
>
|
||||
{@resource.object.name}
|
||||
</h3>
|
||||
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Team
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr class="mt-4 mb-4 flex-grow border-t border-gray-200 dark:border-gray-600" />
|
||||
<div class="text-sm truncate">
|
||||
Team identifier:
|
||||
<code class="font-mono">{@resource.object.identifier |> String.slice(0, 8)}</code>
|
||||
<br />
|
||||
Owned by: {@resource.object.owners
|
||||
|> Enum.map(& &1.name)
|
||||
|> Enum.join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def handle_event("show-plan-form", _, socket) do
|
||||
{:noreply, assign(socket, show_plan_form?: true)}
|
||||
end
|
||||
|
||||
def handle_event("hide-plan-form", _, socket) do
|
||||
{:noreply, assign(socket, show_plan_form?: false)}
|
||||
end
|
||||
|
||||
def handle_event("save-team", %{"team" => params}, socket) do
|
||||
changeset = Plausible.Teams.Team.crm_changeset(socket.assigns.team, params)
|
||||
|
||||
case Plausible.Repo.update(changeset) do
|
||||
{:ok, team} ->
|
||||
success(socket, "Team saved")
|
||||
{:noreply, assign(socket, team: team, form: to_form(changeset))}
|
||||
|
||||
{:error, changeset} ->
|
||||
failure(socket, "Error saving team: #{inspect(changeset.errors)}")
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("save-plan", %{"enterprise_plan" => params}, socket) do
|
||||
params = Map.put(params, "features", Enum.reject(params["features[]"], &(&1 == "false")))
|
||||
params = sanitize_params(params)
|
||||
changeset = EnterprisePlan.changeset(%EnterprisePlan{team_id: socket.assigns.team.id}, params)
|
||||
|
||||
case Plausible.Repo.insert(changeset) do
|
||||
{:ok, _plan} ->
|
||||
success(socket, "Plan saved")
|
||||
plans = get_plans(socket.assigns.team.id)
|
||||
|
||||
{:noreply,
|
||||
assign(socket, plans: plans, plan_form: to_form(changeset), show_plan_form?: false)}
|
||||
|
||||
{:error, changeset} ->
|
||||
failure(socket, "Error saving team: #{inspect(changeset.errors)}")
|
||||
{:noreply, assign(socket, plan_form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("unlock", _, socket) do
|
||||
{:noreply, unlock_team(socket)}
|
||||
end
|
||||
|
||||
def handle_event("lock", _, socket) do
|
||||
{:noreply, lock_team(socket)}
|
||||
end
|
||||
|
||||
def team_bg(term) do
|
||||
list = [
|
||||
"bg-blue-500",
|
||||
"bg-blue-600",
|
||||
"bg-blue-700",
|
||||
"bg-blue-800",
|
||||
"bg-cyan-500",
|
||||
"bg-cyan-600",
|
||||
"bg-cyan-700",
|
||||
"bg-cyan-800",
|
||||
"bg-red-500",
|
||||
"bg-red-600",
|
||||
"bg-red-700",
|
||||
"bg-red-800",
|
||||
"bg-green-500",
|
||||
"bg-green-600",
|
||||
"bg-green-700",
|
||||
"bg-green-800",
|
||||
"bg-yellow-500",
|
||||
"bg-yellow-600",
|
||||
"bg-yellow-700",
|
||||
"bg-yellow-800",
|
||||
"bg-orange-500",
|
||||
"bg-orange-600",
|
||||
"bg-orange-700",
|
||||
"bg-orange-800",
|
||||
"bg-purple-500",
|
||||
"bg-purple-600",
|
||||
"bg-purple-700",
|
||||
"bg-purple-800",
|
||||
"bg-gray-500",
|
||||
"bg-gray-600",
|
||||
"bg-gray-700",
|
||||
"bg-gray-800",
|
||||
"bg-emerald-500",
|
||||
"bg-emerald-600",
|
||||
"bg-emerald-700",
|
||||
"bg-emerald-800"
|
||||
]
|
||||
|
||||
idx = :erlang.phash2(term, length(list))
|
||||
Enum.at(list, idx)
|
||||
end
|
||||
|
||||
def subscription_status(team) do
|
||||
cond do
|
||||
team && team.subscription ->
|
||||
status_str =
|
||||
PlausibleWeb.SettingsView.present_subscription_status(team.subscription.status)
|
||||
|
||||
if team.subscription.paddle_subscription_id do
|
||||
assigns = %{status_str: status_str, subscription: team.subscription}
|
||||
|
||||
~H"""
|
||||
<.styled_link new_tab={true} href={manage_url(@subscription)}>{@status_str}</.styled_link>
|
||||
"""
|
||||
else
|
||||
status_str
|
||||
end
|
||||
|
||||
Plausible.Teams.on_trial?(team) ->
|
||||
"On trial"
|
||||
|
||||
true ->
|
||||
"Trial expired"
|
||||
end
|
||||
end
|
||||
|
||||
defp manage_url(%{paddle_subscription_id: paddle_id} = _subscription) do
|
||||
Plausible.Billing.PaddleApi.vendors_domain() <>
|
||||
"/subscriptions/customers/manage/" <> paddle_id
|
||||
end
|
||||
|
||||
def subscription_plan(team) do
|
||||
subscription = team.subscription
|
||||
|
||||
if Subscription.Status.active?(subscription) && subscription.paddle_subscription_id do
|
||||
quota = PlausibleWeb.AuthView.subscription_quota(subscription)
|
||||
interval = PlausibleWeb.AuthView.subscription_interval(subscription)
|
||||
|
||||
assigns = %{quota: quota, interval: interval, subscription: subscription}
|
||||
|
||||
~H"""
|
||||
<.styled_link new_tab={true} href={manage_url(@subscription)}>
|
||||
{@quota} ({@interval})
|
||||
</.styled_link>
|
||||
"""
|
||||
else
|
||||
"--"
|
||||
end
|
||||
end
|
||||
|
||||
def grace_period_status(team) do
|
||||
grace_period = team.grace_period
|
||||
|
||||
case grace_period do
|
||||
nil ->
|
||||
"--"
|
||||
|
||||
%{manual_lock: true, is_over: true} ->
|
||||
"Manually locked"
|
||||
|
||||
%{manual_lock: true, is_over: false} ->
|
||||
"Waiting for manual lock"
|
||||
|
||||
%{is_over: true} ->
|
||||
"ended"
|
||||
|
||||
%{end_date: %Date{} = end_date} ->
|
||||
days_left = Date.diff(end_date, Date.utc_today())
|
||||
"#{days_left} days left"
|
||||
end
|
||||
end
|
||||
|
||||
defp lock_team(socket) do
|
||||
if socket.assigns.team.grace_period do
|
||||
team = socket.assigns.team
|
||||
Plausible.Billing.SiteLocker.set_lock_status_for(team, true)
|
||||
Plausible.Teams.end_grace_period(team)
|
||||
|
||||
success(socket, "Team locked. Grace period ended.")
|
||||
assign(socket, team: team)
|
||||
else
|
||||
failure(socket, "No grace period")
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp unlock_team(socket) do
|
||||
if socket.assigns.team.grace_period do
|
||||
team =
|
||||
socket.assigns.team
|
||||
|> Plausible.Teams.remove_grace_period()
|
||||
|> Plausible.Billing.SiteLocker.set_lock_status_for(false)
|
||||
|
||||
success(socket, "Team unlocked. Grace period removed.")
|
||||
assign(socket, team: team)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp monthly_pageviews_usage(usage, limit) do
|
||||
usage
|
||||
|> Enum.sort_by(fn {_cycle, usage} -> usage.date_range.first end, :desc)
|
||||
|> Enum.map(fn {cycle, usage} ->
|
||||
{cycle, PlausibleWeb.TextHelpers.format_date_range(usage.date_range), usage.total, limit}
|
||||
end)
|
||||
end
|
||||
|
||||
defp get_plans(team_id) do
|
||||
Repo.all(
|
||||
from ep in EnterprisePlan,
|
||||
where: ep.team_id == ^team_id,
|
||||
order_by: [desc: :id]
|
||||
)
|
||||
end
|
||||
|
||||
defp number_format(number) when is_integer(number) do
|
||||
Cldr.Number.to_string!(number)
|
||||
end
|
||||
|
||||
defp number_format(other), do: other
|
||||
|
||||
@numeric_fields [
|
||||
"team_id",
|
||||
"paddle_plan_id",
|
||||
"monthly_pageview_limit",
|
||||
"site_limit",
|
||||
"team_member_limit",
|
||||
"hourly_api_request_limit"
|
||||
]
|
||||
|
||||
defp sanitize_params(params) do
|
||||
params
|
||||
|> Enum.map(&clear_param/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp clear_param({key, value}) when key in @numeric_fields do
|
||||
value =
|
||||
value
|
||||
|> to_string()
|
||||
|> String.replace(~r/[^0-9-]/, "")
|
||||
|> String.trim()
|
||||
|
||||
{key, value}
|
||||
end
|
||||
|
||||
defp clear_param({key, value}) when is_binary(value) do
|
||||
{key, String.trim(value)}
|
||||
end
|
||||
|
||||
defp clear_param(other) do
|
||||
other
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
defmodule PlausibleWeb.CustomerSupport.Live.User do
|
||||
@moduledoc false
|
||||
use Plausible.CustomerSupport.Resource, :component
|
||||
use PlausibleWeb.Live.Flash
|
||||
|
||||
def update(assigns, socket) do
|
||||
user = socket.assigns[:user] || Resource.User.get(assigns.resource_id)
|
||||
form = user |> Plausible.Auth.User.changeset() |> to_form()
|
||||
{:ok, assign(socket, user: user, form: form)}
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="p-6">
|
||||
<div class="sm:flex sm:items-center sm:justify-between">
|
||||
<div class="sm:flex sm:space-x-5">
|
||||
<div class="shrink-0">
|
||||
<div class="rounded-full p-1 flex items-center justify-center">
|
||||
<img
|
||||
src={Plausible.Auth.User.profile_img_url(@user)}
|
||||
class="w-14 rounded-full bg-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 text-center sm:mt-0 sm:pt-1 sm:text-left">
|
||||
<p class="text-xl font-bold sm:text-2xl">
|
||||
{@user.name}
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
<span>{@user.email}</span>
|
||||
|
||||
<span :if={@user.previous_email}>(previously: {@user.previous_email})</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<.table rows={@user.team_memberships}>
|
||||
<:thead>
|
||||
<.th>Team</.th>
|
||||
<.th>Role</.th>
|
||||
</:thead>
|
||||
<:tbody :let={membership}>
|
||||
<.td>
|
||||
<.styled_link patch={"/cs/teams/team/#{membership.team.id}"}>
|
||||
{membership.team.name}
|
||||
</.styled_link>
|
||||
</.td>
|
||||
<.td>{membership.role}</.td>
|
||||
</:tbody>
|
||||
</.table>
|
||||
|
||||
<.form :let={f} for={@form} phx-target={@myself} phx-submit="save-user" class="mt-8">
|
||||
<.input type="textarea" field={f[:notes]} label="Notes" />
|
||||
<.button phx-target={@myself} type="submit">
|
||||
Save
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def render_result(assigns) do
|
||||
~H"""
|
||||
<div class="flex-1 -mt-px w-full">
|
||||
<div class="w-full flex items-center justify-between space-x-4">
|
||||
<img src={Plausible.Auth.User.profile_img_url(@resource.object)} class="h-5 w-5 rounded-full" />
|
||||
<h3
|
||||
class="text-gray-900 font-medium text-lg truncate dark:text-gray-100"
|
||||
style="width: calc(100% - 4rem)"
|
||||
>
|
||||
{@resource.object.name}
|
||||
</h3>
|
||||
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
User
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr class="mt-4 mb-4 flex-grow border-t border-gray-200 dark:border-gray-600" />
|
||||
<div class="text-sm truncate">
|
||||
{@resource.object.name} <{@resource.object.email}> <br />
|
||||
<br /> Owns {length(@resource.object.owned_teams)} team(s)
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def handle_event("save-user", %{"user" => params}, socket) do
|
||||
changeset = Plausible.Auth.User.changeset(socket.assigns.user, params)
|
||||
|
||||
case Plausible.Repo.update(changeset) do
|
||||
{:ok, user} ->
|
||||
success(socket, "User updated")
|
||||
{:noreply, assign(socket, user: user, form: to_form(changeset))}
|
||||
|
||||
{:error, changeset} ->
|
||||
failure(socket, inspect(changeset.errors))
|
||||
send(socket.root_pid, {:failure, inspect(changeset.errors)})
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -85,7 +85,7 @@ defmodule PlausibleWeb.HelpScoutView do
|
|||
|
||||
<div class="sites">
|
||||
<p class="label">
|
||||
Owner of <b><a href={@sites_link} target="_blank">{@sites_count} sites</a></b>
|
||||
Owner of {@sites_count} sites
|
||||
</p>
|
||||
<p class="value"></p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -187,6 +187,10 @@ defmodule Plausible.Auth.User do
|
|||
Path.join(PlausibleWeb.Endpoint.url(), ["avatar/", hash])
|
||||
end
|
||||
|
||||
def profile_img_url(email) when is_binary(email) do
|
||||
profile_img_url(%__MODULE__{email: email})
|
||||
end
|
||||
|
||||
defp validate_email_changed(changeset) do
|
||||
if !get_change(changeset, :email) && !changeset.errors[:email] do
|
||||
add_error(changeset, :email, "can't be the same", validation: :different_email)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ defmodule Plausible.Billing.Ecto.FeatureList do
|
|||
def load(list), do: Ecto.Type.load(type(), list)
|
||||
def dump(list), do: Ecto.Type.dump(type(), list)
|
||||
|
||||
# XXX: remove with kaffy
|
||||
def render_form(_conn, changeset, form, field, _options) do
|
||||
features = Ecto.Changeset.get_field(changeset, field)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ defmodule Plausible.Billing.Ecto.Limit do
|
|||
def dump(:unlimited), do: {:ok, -1}
|
||||
def dump(other), do: Ecto.Type.dump(:integer, other)
|
||||
|
||||
# XXX: remove with kaffy
|
||||
def render_form(_conn, changeset, form, field, _options) do
|
||||
{:ok, value} = changeset |> Ecto.Changeset.get_field(field) |> dump()
|
||||
|
||||
|
|
|
|||
|
|
@ -29,9 +29,14 @@ defmodule Plausible.Billing.EnterprisePlan do
|
|||
timestamps()
|
||||
end
|
||||
|
||||
@max round(:math.pow(2, 31))
|
||||
|
||||
def changeset(model, attrs \\ %{}) do
|
||||
model
|
||||
|> cast(attrs, @required_fields)
|
||||
|> validate_number(:monthly_pageview_limit, less_than: @max)
|
||||
|> validate_number(:site_limit, less_than: @max)
|
||||
|> validate_number(:hourly_api_request_limit, less_than: @max)
|
||||
|> validate_required(@required_fields)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -71,13 +71,13 @@ defmodule Plausible.Billing.Feature do
|
|||
:ok | {:error, :upgrade_required} | {:error, :not_implemented}
|
||||
|
||||
@features [
|
||||
Plausible.Billing.Feature.Goals,
|
||||
Plausible.Billing.Feature.StatsAPI,
|
||||
Plausible.Billing.Feature.SitesAPI,
|
||||
Plausible.Billing.Feature.Props,
|
||||
Plausible.Billing.Feature.Funnels,
|
||||
Plausible.Billing.Feature.Goals,
|
||||
Plausible.Billing.Feature.RevenueGoals,
|
||||
Plausible.Billing.Feature.SiteSegments
|
||||
Plausible.Billing.Feature.SiteSegments,
|
||||
Plausible.Billing.Feature.SitesAPI,
|
||||
Plausible.Billing.Feature.StatsAPI
|
||||
]
|
||||
|
||||
# Generate a union type for features
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ defmodule Plausible.CrmExtensions do
|
|||
]
|
||||
end
|
||||
|
||||
def javascripts(%{assigns: %{context: "sites", resource: "site", entry: %{domain: domain}}}) do
|
||||
def javascripts(%{
|
||||
assigns: %{context: "sites", resource: "site", entry: %{domain: domain, id: id}}
|
||||
}) do
|
||||
base_url = PlausibleWeb.Endpoint.url()
|
||||
|
||||
[
|
||||
|
|
@ -58,7 +60,7 @@ defmodule Plausible.CrmExtensions do
|
|||
if (cardBody) {
|
||||
const buttonDOM = document.createElement("div")
|
||||
buttonDOM.className = "mb-3 w-full text-right"
|
||||
buttonDOM.innerHTML = '<div><a class="btn btn-outline-primary" href="#{base_url <> "/" <> URI.encode_www_form(domain)}" target="_blank">Open Dashboard</a></div>'
|
||||
buttonDOM.innerHTML = '<div><a class="btn btn-outline-primary" href="#{base_url <> "/" <> URI.encode_www_form(domain)}" target="_blank">Open Dashboard</a><a class="mt-1 ml-4" target="_blank" href="/cs/sites/site/#{id}">Open in CS</a></div>'
|
||||
cardBody.prepend(buttonDOM)
|
||||
}
|
||||
})()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ defmodule Plausible.Teams.Management.Layout.Entry do
|
|||
"""
|
||||
alias Plausible.Teams
|
||||
|
||||
defstruct [:email, :name, :role, :type, :meta, :queued_op]
|
||||
defstruct [:email, :name, :role, :type, :meta, :queued_op, :id]
|
||||
|
||||
@type t() :: %__MODULE__{}
|
||||
|
||||
|
|
@ -19,6 +19,7 @@ defmodule Plausible.Teams.Management.Layout.Entry do
|
|||
when is_integer(existing) do
|
||||
%__MODULE__{
|
||||
name: "Invited User",
|
||||
id: 0,
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
type: :invitation_sent,
|
||||
|
|
@ -30,6 +31,7 @@ defmodule Plausible.Teams.Management.Layout.Entry do
|
|||
def new(%Teams.Invitation{id: nil} = pending, attrs) do
|
||||
%__MODULE__{
|
||||
name: "Invited User",
|
||||
id: 0,
|
||||
email: pending.email,
|
||||
role: pending.role,
|
||||
type: :invitation_pending,
|
||||
|
|
@ -42,6 +44,7 @@ defmodule Plausible.Teams.Management.Layout.Entry do
|
|||
%__MODULE__{
|
||||
name: membership.user.name,
|
||||
role: membership.role,
|
||||
id: membership.user.id,
|
||||
email: membership.user.email,
|
||||
type: :membership,
|
||||
meta: membership
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ defmodule PlausibleWeb.Components.Generic do
|
|||
attr(:href, :string, default: "#")
|
||||
attr(:new_tab, :boolean, default: false)
|
||||
attr(:class, :string, default: "")
|
||||
attr(:rest, :global)
|
||||
attr(:rest, :global, include: ~w(patch))
|
||||
attr(:method, :string, default: "get")
|
||||
slot(:inner_block)
|
||||
|
||||
|
|
@ -592,6 +592,7 @@ defmodule PlausibleWeb.Components.Generic do
|
|||
attr :truncate, :boolean, default: false
|
||||
attr :max_width, :string, default: ""
|
||||
attr :height, :string, default: ""
|
||||
attr :class, :string, default: ""
|
||||
attr :actions, :boolean, default: nil
|
||||
attr :hide_on_mobile, :boolean, default: nil
|
||||
attr :rest, :global
|
||||
|
|
@ -614,7 +615,8 @@ defmodule PlausibleWeb.Components.Generic do
|
|||
@truncate && "truncate",
|
||||
@max_width,
|
||||
@actions && "flex text-right justify-end",
|
||||
@hide_on_mobile && "hidden md:table-cell"
|
||||
@hide_on_mobile && "hidden md:table-cell",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
|
|
@ -732,10 +734,10 @@ defmodule PlausibleWeb.Components.Generic do
|
|||
|
||||
def filter_bar(assigns) do
|
||||
~H"""
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="text-gray-800 inline-flex items-center">
|
||||
<div class="mb-6 flex items-center justify-between" x-data>
|
||||
<div :if={@filtering_enabled?} class="relative rounded-md shadow-sm flex">
|
||||
<form id="filter-form" phx-change="filter" class="flex items-center">
|
||||
<div class="text-gray-800 inline-flex items-center">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<Heroicons.magnifying_glass class="feather mr-1 dark:text-gray-300" />
|
||||
</div>
|
||||
|
|
@ -744,8 +746,15 @@ defmodule PlausibleWeb.Components.Generic do
|
|||
name="filter-text"
|
||||
id="filter-text"
|
||||
class="w-36 sm:w-full pl-8 text-sm shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
|
||||
placeholder={@placeholder}
|
||||
placeholder="Press / to search"
|
||||
x-ref="filter_text"
|
||||
phx-debounce={200}
|
||||
autocomoplete="off"
|
||||
x-on:keydown.prevent.slash.window="$refs.filter_text.focus(); $refs.filter_text.select();"
|
||||
x-on:keydown.escape="$refs.filter_text.blur(); $refs.reset_filter?.dispatchEvent(new Event('click', {bubbles: true, cancelable: true}));"
|
||||
value={@filter_text}
|
||||
x-on:focus={"$refs.filter_text.placeholder = '#{@placeholder}';"}
|
||||
x-on:blur="$refs.filter_text.placeholder = 'Press / to search';"
|
||||
/>
|
||||
|
||||
<Heroicons.backspace
|
||||
|
|
@ -753,9 +762,10 @@ defmodule PlausibleWeb.Components.Generic do
|
|||
class="feather ml-2 cursor-pointer hover:text-red-500 dark:text-gray-300 dark:hover:text-red-500"
|
||||
phx-click="reset-filter-text"
|
||||
id="reset-filter"
|
||||
x-ref="reset_filter"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ defmodule PlausibleWeb.AdminController do
|
|||
teams_list = Plausible.Auth.UserAdmin.teams(user.owned_teams)
|
||||
|
||||
html_response = """
|
||||
<a style="margin-bottom: 2em; float: right;" target="_blank" href="/cs/users/user/#{user_id}">Open in CS</a>
|
||||
<div style="margin-bottom: 1.1em;">
|
||||
<p><b>Owned teams:</b></p>
|
||||
#{teams_list}
|
||||
|
|
@ -186,6 +187,7 @@ defmodule PlausibleWeb.AdminController do
|
|||
|
||||
defp usage_and_limits_html(team, usage, limits, embed?) do
|
||||
content = """
|
||||
<a style="margin-bottom: 2em; float: right;" target="_blank" href="/cs/teams/team/#{team.id}">Open in CS</a>
|
||||
<ul>
|
||||
<li>Team: <b>#{html_escape(Teams.name(team))}</b></li>
|
||||
<li>Setup: <b>#{if(team.setup_complete, do: "Yes", else: "No")}</b></li>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
|
|||
which can be searched by typing in the input field.
|
||||
|
||||
The component renders an input field with a dropdown anchor and a
|
||||
hidden input field for submitting the selected value.
|
||||
hidden input field for submitting the selected value. In order to remain
|
||||
functional, the component must be embedded in a `<form/>`.
|
||||
|
||||
The number of options displayed in the dropdown is limited to 15
|
||||
by default but can be customized. When a user types into the input
|
||||
|
|
|
|||
|
|
@ -90,19 +90,27 @@ defmodule PlausibleWeb.Live.Components.Form do
|
|||
end
|
||||
|
||||
def input(%{type: "checkbox"} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<div class={[
|
||||
"flex flex-inline items-center sm:justify-start justify-center gap-x-2",
|
||||
@mt? && "mt-2"
|
||||
]}>
|
||||
<.label for={@id} class="gap-x-2 flex flex-inline items-center sm:justify-start justify-center ">
|
||||
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
|
||||
<input
|
||||
type="checkbox"
|
||||
value={@value || "true"}
|
||||
value={assigns[:value] || "true"}
|
||||
checked={@checked}
|
||||
id={@id}
|
||||
name={@name}
|
||||
class="block h-5 w-5 rounded dark:bg-gray-700 border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
||||
/>
|
||||
<.label for={@id}>{@label}</.label>
|
||||
{@label}
|
||||
</.label>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
|
@ -137,6 +145,22 @@ defmodule PlausibleWeb.Live.Components.Form do
|
|||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<div class="mt-2">
|
||||
<.label for={@id}>{@label}</.label>
|
||||
<textarea
|
||||
id={@id}
|
||||
rows="6"
|
||||
name={@name}
|
||||
class="block w-full textarea border-1 border-gray-300 rounded-md p-4 text-sm text-gray-700 dark:border-gray-500 dark:bg-gray-900 dark:text-gray-300"
|
||||
{@rest}
|
||||
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
errors =
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ defmodule PlausibleWeb.Live.Components.Team do
|
|||
<.dropdown id={"role-dropdown-#{@user.email}"}>
|
||||
<:button class="role bg-transparent text-gray-800 dark:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-700 focus-visible:outline-gray-100 whitespace-nowrap truncate inline-flex items-center gap-x-2 font-medium rounded-md px-3.5 py-2.5 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 disabled:bg-gray-400 dark:disabled:text-white dark:disabled:text-gray-400 dark:disabled:bg-gray-700">
|
||||
{@role |> to_string() |> String.capitalize()}
|
||||
<Heroicons.chevron_down mini class="size-4 mt-0.5" />
|
||||
<Heroicons.chevron_down :if={not @disabled} mini class="size-4 mt-0.5" />
|
||||
</:button>
|
||||
<:menu class="dropdown-items max-w-60">
|
||||
<.role_item
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ defmodule PlausibleWeb.Live.Sites do
|
|||
~H"""
|
||||
<.flash_messages flash={@flash} />
|
||||
<div
|
||||
x-ref="invitation_data"
|
||||
x-data={"{selectedInvitation: null, invitationOpen: false, invitations: #{Enum.map(@invitations, &({&1.invitation.invitation_id, &1})) |> Enum.into(%{}) |> Jason.encode!}}"}
|
||||
x-on:keydown.escape.window="invitationOpen = false"
|
||||
class="container pt-6"
|
||||
|
|
@ -539,43 +540,7 @@ defmodule PlausibleWeb.Live.Sites do
|
|||
|
||||
def search_form(assigns) do
|
||||
~H"""
|
||||
<form id="filter-form" phx-change="filter" action={@uri} method="GET">
|
||||
<div class="text-gray-800 text-sm inline-flex items-center">
|
||||
<div class="relative rounded-md flex">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<Heroicons.magnifying_glass class="feather mr-1 dark:text-gray-300" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="filter_text"
|
||||
id="filter-text"
|
||||
phx-debounce={200}
|
||||
class="pl-8 dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md"
|
||||
placeholder="Press / to search sites"
|
||||
autocomplete="off"
|
||||
value={@filter_text}
|
||||
x-ref="filter_text"
|
||||
x-on:keydown.escape="$refs.filter_text.blur(); $refs.reset_filter?.dispatchEvent(new Event('click', {bubbles: true, cancelable: true}));"
|
||||
x-on:keydown.prevent.slash.window="$refs.filter_text.focus(); $refs.filter_text.select();"
|
||||
x-on:blur="$refs.filter_text.placeholder = 'Press / to search sites';"
|
||||
x-on:focus="$refs.filter_text.placeholder = 'Search sites';"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:if={String.trim(@filter_text) != ""}
|
||||
class="phx-change-loading:hidden ml-2"
|
||||
phx-click="reset-filter-text"
|
||||
id="reset-filter"
|
||||
x-ref="reset_filter"
|
||||
type="button"
|
||||
>
|
||||
<Heroicons.backspace class="feather hover:text-red-500 dark:text-gray-300 dark:hover:text-red-500" />
|
||||
</button>
|
||||
|
||||
<.spinner class="hidden phx-change-loading:inline ml-2" />
|
||||
</div>
|
||||
</form>
|
||||
<.filter_bar filter_text={@filter_text} placeholder="Search Sites"></.filter_bar>
|
||||
"""
|
||||
end
|
||||
|
||||
|
|
@ -635,13 +600,13 @@ defmodule PlausibleWeb.Live.Sites do
|
|||
|
||||
def handle_event(
|
||||
"filter",
|
||||
%{"filter_text" => filter_text},
|
||||
%{"filter-text" => filter_text},
|
||||
%{assigns: %{filter_text: filter_text}} = socket
|
||||
) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("filter", %{"filter_text" => filter_text}, socket) do
|
||||
def handle_event("filter", %{"filter-text" => filter_text}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> reset_pagination()
|
||||
|
|
|
|||
|
|
@ -108,6 +108,16 @@ defmodule PlausibleWeb.Router do
|
|||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
scope alias: PlausibleWeb.Live, assigns: %{connect_live_socket: true} do
|
||||
pipe_through [:browser, :csrf, :app_layout, :flags]
|
||||
|
||||
live "/cs", CustomerSupport, :index, as: :customer_support
|
||||
|
||||
live "/cs/:any/:resource/:id", CustomerSupport, :details, as: :customer_support_resource
|
||||
end
|
||||
end
|
||||
|
||||
on_ee do
|
||||
scope path: "/flags" do
|
||||
pipe_through :flags
|
||||
|
|
|
|||
|
|
@ -37,11 +37,19 @@
|
|||
}>
|
||||
<.styled_link
|
||||
class="text-sm mr-6"
|
||||
href={PlausibleWeb.Endpoint.url() <> "/crm/sites/site/#{@conn.assigns[:site].id}"}
|
||||
href={PlausibleWeb.Endpoint.url() <> "/crm/sites/site/#{@conn.assigns.site.id}"}
|
||||
new_tab={true}
|
||||
>
|
||||
CRM
|
||||
</.styled_link>
|
||||
|
||||
<.styled_link
|
||||
class="text-sm mr-6"
|
||||
href={"/cs/sites/site/#{@conn.assigns.site.id}"}
|
||||
new_tab={true}
|
||||
>
|
||||
CS
|
||||
</.styled_link>
|
||||
</li>
|
||||
<li
|
||||
:if={ee?() and Plausible.Teams.on_trial?(@conn.assigns[:current_team])}
|
||||
|
|
|
|||
|
|
@ -59,10 +59,7 @@ defmodule Plausible.HelpScoutTest do
|
|||
stub_help_scout_requests(email)
|
||||
team = team_of(user)
|
||||
|
||||
crm_url = "#{PlausibleWeb.Endpoint.url()}/crm/teams/team/#{team.id}"
|
||||
|
||||
owned_sites_url =
|
||||
"#{PlausibleWeb.Endpoint.url()}/crm/sites/site?custom_search=#{URI.encode_www_form(team.identifier)}"
|
||||
crm_url = "#{PlausibleWeb.Endpoint.url()}/cs/teams/team/#{team.id}"
|
||||
|
||||
assert {:ok,
|
||||
%{
|
||||
|
|
@ -70,8 +67,7 @@ defmodule Plausible.HelpScoutTest do
|
|||
status_label: "Trial",
|
||||
plan_link: "#",
|
||||
plan_label: "None",
|
||||
sites_count: 0,
|
||||
sites_link: ^owned_sites_url
|
||||
sites_count: 0
|
||||
}} = HelpScout.get_details_for_customer("500")
|
||||
end
|
||||
|
||||
|
|
@ -408,12 +404,10 @@ defmodule Plausible.HelpScoutTest do
|
|||
describe "get_details_for_emails/2" do
|
||||
test "returns details for user and persists mapping" do
|
||||
%{email: email} = user = new_user(trial_expiry_date: Date.utc_today())
|
||||
|
||||
team = team_of(user)
|
||||
|
||||
crm_url = "#{PlausibleWeb.Endpoint.url()}/crm/teams/team/#{team.id}"
|
||||
|
||||
owned_sites_url =
|
||||
"#{PlausibleWeb.Endpoint.url()}/crm/sites/site?custom_search=#{URI.encode_www_form(team.identifier)}"
|
||||
crm_url = "#{PlausibleWeb.Endpoint.url()}/cs/teams/team/#{team.id}"
|
||||
|
||||
assert {:ok,
|
||||
%{
|
||||
|
|
@ -421,8 +415,7 @@ defmodule Plausible.HelpScoutTest do
|
|||
status_label: "Trial",
|
||||
plan_link: "#",
|
||||
plan_label: "None",
|
||||
sites_count: 0,
|
||||
sites_link: ^owned_sites_url
|
||||
sites_count: 0
|
||||
}} = HelpScout.get_details_for_emails([email], "123")
|
||||
|
||||
assert {:ok, ^email} = HelpScout.lookup_mapping("123")
|
||||
|
|
@ -449,10 +442,7 @@ defmodule Plausible.HelpScoutTest do
|
|||
new_site(owner: user2)
|
||||
team2 = team_of(user2)
|
||||
|
||||
crm_url = "#{PlausibleWeb.Endpoint.url()}/crm/teams/team/#{team2.id}"
|
||||
|
||||
owned_sites_url =
|
||||
"#{PlausibleWeb.Endpoint.url()}/crm/sites/site?custom_search=#{URI.encode_www_form(team2.identifier)}"
|
||||
crm_url = "#{PlausibleWeb.Endpoint.url()}/cs/teams/team/#{team2.id}"
|
||||
|
||||
assert {:ok,
|
||||
%{
|
||||
|
|
@ -460,8 +450,7 @@ defmodule Plausible.HelpScoutTest do
|
|||
status_label: "Trial",
|
||||
plan_link: "#",
|
||||
plan_label: "None",
|
||||
sites_count: 2,
|
||||
sites_link: ^owned_sites_url
|
||||
sites_count: 2
|
||||
}} = HelpScout.get_details_for_emails([user1.email, user2.email], "123")
|
||||
|
||||
user2_email = user2.email
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ defmodule PlausibleWeb.HelpScoutControllerTest do
|
|||
"/helpscout/callback?conversation-id=123&customer-id=500&X-HelpScout-Signature=#{signature}"
|
||||
)
|
||||
|
||||
assert html_response(conn, 200) =~ "/crm/auth/user/#{user.id}"
|
||||
assert html_response(conn, 200) =~ "/cs/users/user/#{user.id}"
|
||||
end
|
||||
|
||||
test "returns error on failure", %{conn: conn} do
|
||||
|
|
@ -129,7 +129,7 @@ defmodule PlausibleWeb.HelpScoutControllerTest do
|
|||
)
|
||||
|
||||
assert html = html_response(conn, 200)
|
||||
assert html =~ "/crm/auth/user/#{user.id}"
|
||||
assert html =~ "/cs/users/user/#{user.id}"
|
||||
assert html =~ "Some note<br>\nwith new line"
|
||||
end
|
||||
|
||||
|
|
@ -158,7 +158,7 @@ defmodule PlausibleWeb.HelpScoutControllerTest do
|
|||
)
|
||||
|
||||
assert html = html_response(conn, 200)
|
||||
assert html =~ "/crm/auth/user/#{user.id}"
|
||||
assert html =~ "/cs/users/user/#{user.id}"
|
||||
assert html =~ "Some user notes"
|
||||
assert html =~ "My Personal Sites"
|
||||
assert html =~ "HS Integration Test Team"
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ defmodule PlausibleWeb.Live.SitesTest do
|
|||
|
||||
{:ok, lv, _html} = live(conn, "/sites")
|
||||
|
||||
type_into_input(lv, "filter_text", "firs")
|
||||
type_into_input(lv, "filter-text", "firs")
|
||||
html = render(lv)
|
||||
|
||||
assert html =~ "first.example.com"
|
||||
|
|
@ -267,7 +267,7 @@ defmodule PlausibleWeb.Live.SitesTest do
|
|||
assert html =~ "page=2"
|
||||
refute html =~ "page=1"
|
||||
|
||||
type_into_input(lv, "filter_text", "anot")
|
||||
type_into_input(lv, "filter-text", "anot")
|
||||
html = render(lv)
|
||||
|
||||
assert html =~ "first.another.example.com"
|
||||
|
|
@ -355,7 +355,7 @@ defmodule PlausibleWeb.Live.SitesTest do
|
|||
|
||||
defp get_invitation_data(html) do
|
||||
html
|
||||
|> text_of_attr("div[x-data]", "x-data")
|
||||
|> text_of_attr("div[x-ref=\"invitation_data\"][x-data]", "x-data")
|
||||
|> String.trim("dropdown")
|
||||
|> String.replace("selectedInvitation:", "\"selectedInvitation\":")
|
||||
|> String.replace("invitationOpen:", "\"invitationOpen\":")
|
||||
|
|
|
|||
Loading…
Reference in New Issue