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 'phoenix_html'
|
||||||
import { Socket } from 'phoenix'
|
import { Socket } from 'phoenix'
|
||||||
import { LiveSocket } from 'phoenix_live_view'
|
import { LiveSocket } from 'phoenix_live_view'
|
||||||
|
import topbar from 'topbar'
|
||||||
/* eslint-enable import/no-unresolved */
|
/* eslint-enable import/no-unresolved */
|
||||||
|
|
||||||
import Alpine from 'alpinejs'
|
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()
|
liveSocket.connect()
|
||||||
window.liveSocket = liveSocket
|
window.liveSocket = liveSocket
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@
|
||||||
"react-popper": "^2.3.0",
|
"react-popper": "^2.3.0",
|
||||||
"react-router-dom": "^6.25.1",
|
"react-router-dom": "^6.25.1",
|
||||||
"react-transition-group": "^4.4.2",
|
"react-transition-group": "^4.4.2",
|
||||||
|
"topbar": "^3.0.0",
|
||||||
"topojson-client": "^3.1.0",
|
"topojson-client": "^3.1.0",
|
||||||
"url-search-params-polyfill": "^8.2.5",
|
"url-search-params-polyfill": "^8.2.5",
|
||||||
"visionscarto-world-atlas": "^1.0.0"
|
"visionscarto-world-atlas": "^1.0.0"
|
||||||
|
|
@ -10263,6 +10264,12 @@
|
||||||
"node": ">=8.0"
|
"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": {
|
"node_modules/topojson-client": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
"react-popper": "^2.3.0",
|
"react-popper": "^2.3.0",
|
||||||
"react-router-dom": "^6.25.1",
|
"react-router-dom": "^6.25.1",
|
||||||
"react-transition-group": "^4.4.2",
|
"react-transition-group": "^4.4.2",
|
||||||
|
"topbar": "^3.0.0",
|
||||||
"topojson-client": "^3.1.0",
|
"topojson-client": "^3.1.0",
|
||||||
"url-search-params-polyfill": "^8.2.5",
|
"url-search-params-polyfill": "^8.2.5",
|
||||||
"visionscarto-world-atlas": "^1.0.0"
|
"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)
|
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,
|
{:ok,
|
||||||
%{
|
%{
|
||||||
|
|
@ -129,19 +136,20 @@ defmodule Plausible.HelpScout do
|
||||||
|
|
||||||
status_link =
|
status_link =
|
||||||
if team do
|
if team do
|
||||||
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :teams, :team, team.id)
|
Routes.customer_support_resource_url(
|
||||||
else
|
PlausibleWeb.Endpoint,
|
||||||
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :auth, :user, user.id)
|
:details,
|
||||||
end
|
:teams,
|
||||||
|
:team,
|
||||||
sites_link =
|
team.id
|
||||||
if team do
|
|
||||||
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site,
|
|
||||||
custom_search: team.identifier
|
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :index, :sites, :site,
|
Routes.customer_support_resource_url(
|
||||||
custom_search: user.email
|
PlausibleWeb.Endpoint,
|
||||||
|
:details,
|
||||||
|
:users,
|
||||||
|
:user,
|
||||||
|
user.id
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -156,8 +164,7 @@ defmodule Plausible.HelpScout do
|
||||||
status_link: status_link,
|
status_link: status_link,
|
||||||
plan_label: plan_label(subscription, plan),
|
plan_label: plan_label(subscription, plan),
|
||||||
plan_link: plan_link(subscription),
|
plan_link: plan_link(subscription),
|
||||||
sites_count: Teams.owned_sites_count(team),
|
sites_count: Teams.owned_sites_count(team)
|
||||||
sites_link: sites_link
|
|
||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
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">
|
<div class="sites">
|
||||||
<p class="label">
|
<p class="label">
|
||||||
Owner of <b><a href={@sites_link} target="_blank">{@sites_count} sites</a></b>
|
Owner of {@sites_count} sites
|
||||||
</p>
|
</p>
|
||||||
<p class="value"></p>
|
<p class="value"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,10 @@ defmodule Plausible.Auth.User do
|
||||||
Path.join(PlausibleWeb.Endpoint.url(), ["avatar/", hash])
|
Path.join(PlausibleWeb.Endpoint.url(), ["avatar/", hash])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def profile_img_url(email) when is_binary(email) do
|
||||||
|
profile_img_url(%__MODULE__{email: email})
|
||||||
|
end
|
||||||
|
|
||||||
defp validate_email_changed(changeset) do
|
defp validate_email_changed(changeset) do
|
||||||
if !get_change(changeset, :email) && !changeset.errors[:email] do
|
if !get_change(changeset, :email) && !changeset.errors[:email] do
|
||||||
add_error(changeset, :email, "can't be the same", validation: :different_email)
|
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 load(list), do: Ecto.Type.load(type(), list)
|
||||||
def dump(list), do: Ecto.Type.dump(type(), list)
|
def dump(list), do: Ecto.Type.dump(type(), list)
|
||||||
|
|
||||||
|
# XXX: remove with kaffy
|
||||||
def render_form(_conn, changeset, form, field, _options) do
|
def render_form(_conn, changeset, form, field, _options) do
|
||||||
features = Ecto.Changeset.get_field(changeset, field)
|
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(:unlimited), do: {:ok, -1}
|
||||||
def dump(other), do: Ecto.Type.dump(:integer, other)
|
def dump(other), do: Ecto.Type.dump(:integer, other)
|
||||||
|
|
||||||
|
# XXX: remove with kaffy
|
||||||
def render_form(_conn, changeset, form, field, _options) do
|
def render_form(_conn, changeset, form, field, _options) do
|
||||||
{:ok, value} = changeset |> Ecto.Changeset.get_field(field) |> dump()
|
{:ok, value} = changeset |> Ecto.Changeset.get_field(field) |> dump()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,14 @@ defmodule Plausible.Billing.EnterprisePlan do
|
||||||
timestamps()
|
timestamps()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@max round(:math.pow(2, 31))
|
||||||
|
|
||||||
def changeset(model, attrs \\ %{}) do
|
def changeset(model, attrs \\ %{}) do
|
||||||
model
|
model
|
||||||
|> cast(attrs, @required_fields)
|
|> 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)
|
|> validate_required(@required_fields)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -71,13 +71,13 @@ defmodule Plausible.Billing.Feature do
|
||||||
:ok | {:error, :upgrade_required} | {:error, :not_implemented}
|
:ok | {:error, :upgrade_required} | {:error, :not_implemented}
|
||||||
|
|
||||||
@features [
|
@features [
|
||||||
Plausible.Billing.Feature.Goals,
|
|
||||||
Plausible.Billing.Feature.StatsAPI,
|
|
||||||
Plausible.Billing.Feature.SitesAPI,
|
|
||||||
Plausible.Billing.Feature.Props,
|
Plausible.Billing.Feature.Props,
|
||||||
Plausible.Billing.Feature.Funnels,
|
Plausible.Billing.Feature.Funnels,
|
||||||
|
Plausible.Billing.Feature.Goals,
|
||||||
Plausible.Billing.Feature.RevenueGoals,
|
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
|
# Generate a union type for features
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,9 @@ defmodule Plausible.CrmExtensions do
|
||||||
]
|
]
|
||||||
end
|
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()
|
base_url = PlausibleWeb.Endpoint.url()
|
||||||
|
|
||||||
[
|
[
|
||||||
|
|
@ -58,7 +60,7 @@ defmodule Plausible.CrmExtensions do
|
||||||
if (cardBody) {
|
if (cardBody) {
|
||||||
const buttonDOM = document.createElement("div")
|
const buttonDOM = document.createElement("div")
|
||||||
buttonDOM.className = "mb-3 w-full text-right"
|
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)
|
cardBody.prepend(buttonDOM)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ defmodule Plausible.Teams.Management.Layout.Entry do
|
||||||
"""
|
"""
|
||||||
alias Plausible.Teams
|
alias Plausible.Teams
|
||||||
|
|
||||||
defstruct [:email, :name, :role, :type, :meta, :queued_op]
|
defstruct [:email, :name, :role, :type, :meta, :queued_op, :id]
|
||||||
|
|
||||||
@type t() :: %__MODULE__{}
|
@type t() :: %__MODULE__{}
|
||||||
|
|
||||||
|
|
@ -19,6 +19,7 @@ defmodule Plausible.Teams.Management.Layout.Entry do
|
||||||
when is_integer(existing) do
|
when is_integer(existing) do
|
||||||
%__MODULE__{
|
%__MODULE__{
|
||||||
name: "Invited User",
|
name: "Invited User",
|
||||||
|
id: 0,
|
||||||
email: invitation.email,
|
email: invitation.email,
|
||||||
role: invitation.role,
|
role: invitation.role,
|
||||||
type: :invitation_sent,
|
type: :invitation_sent,
|
||||||
|
|
@ -30,6 +31,7 @@ defmodule Plausible.Teams.Management.Layout.Entry do
|
||||||
def new(%Teams.Invitation{id: nil} = pending, attrs) do
|
def new(%Teams.Invitation{id: nil} = pending, attrs) do
|
||||||
%__MODULE__{
|
%__MODULE__{
|
||||||
name: "Invited User",
|
name: "Invited User",
|
||||||
|
id: 0,
|
||||||
email: pending.email,
|
email: pending.email,
|
||||||
role: pending.role,
|
role: pending.role,
|
||||||
type: :invitation_pending,
|
type: :invitation_pending,
|
||||||
|
|
@ -42,6 +44,7 @@ defmodule Plausible.Teams.Management.Layout.Entry do
|
||||||
%__MODULE__{
|
%__MODULE__{
|
||||||
name: membership.user.name,
|
name: membership.user.name,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
|
id: membership.user.id,
|
||||||
email: membership.user.email,
|
email: membership.user.email,
|
||||||
type: :membership,
|
type: :membership,
|
||||||
meta: membership
|
meta: membership
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,7 @@ defmodule PlausibleWeb.Components.Generic do
|
||||||
attr(:href, :string, default: "#")
|
attr(:href, :string, default: "#")
|
||||||
attr(:new_tab, :boolean, default: false)
|
attr(:new_tab, :boolean, default: false)
|
||||||
attr(:class, :string, default: "")
|
attr(:class, :string, default: "")
|
||||||
attr(:rest, :global)
|
attr(:rest, :global, include: ~w(patch))
|
||||||
attr(:method, :string, default: "get")
|
attr(:method, :string, default: "get")
|
||||||
slot(:inner_block)
|
slot(:inner_block)
|
||||||
|
|
||||||
|
|
@ -592,6 +592,7 @@ defmodule PlausibleWeb.Components.Generic do
|
||||||
attr :truncate, :boolean, default: false
|
attr :truncate, :boolean, default: false
|
||||||
attr :max_width, :string, default: ""
|
attr :max_width, :string, default: ""
|
||||||
attr :height, :string, default: ""
|
attr :height, :string, default: ""
|
||||||
|
attr :class, :string, default: ""
|
||||||
attr :actions, :boolean, default: nil
|
attr :actions, :boolean, default: nil
|
||||||
attr :hide_on_mobile, :boolean, default: nil
|
attr :hide_on_mobile, :boolean, default: nil
|
||||||
attr :rest, :global
|
attr :rest, :global
|
||||||
|
|
@ -614,7 +615,8 @@ defmodule PlausibleWeb.Components.Generic do
|
||||||
@truncate && "truncate",
|
@truncate && "truncate",
|
||||||
@max_width,
|
@max_width,
|
||||||
@actions && "flex text-right justify-end",
|
@actions && "flex text-right justify-end",
|
||||||
@hide_on_mobile && "hidden md:table-cell"
|
@hide_on_mobile && "hidden md:table-cell",
|
||||||
|
@class
|
||||||
]}
|
]}
|
||||||
{@rest}
|
{@rest}
|
||||||
>
|
>
|
||||||
|
|
@ -732,10 +734,10 @@ defmodule PlausibleWeb.Components.Generic do
|
||||||
|
|
||||||
def filter_bar(assigns) do
|
def filter_bar(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="mb-6 flex items-center justify-between" x-data>
|
||||||
<div class="text-gray-800 inline-flex items-center">
|
<div :if={@filtering_enabled?} class="relative rounded-md shadow-sm flex">
|
||||||
<div :if={@filtering_enabled?} class="relative rounded-md shadow-sm flex">
|
<form id="filter-form" phx-change="filter" class="flex items-center">
|
||||||
<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">
|
<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" />
|
<Heroicons.magnifying_glass class="feather mr-1 dark:text-gray-300" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -744,8 +746,15 @@ defmodule PlausibleWeb.Components.Generic do
|
||||||
name="filter-text"
|
name="filter-text"
|
||||||
id="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"
|
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}
|
value={@filter_text}
|
||||||
|
x-on:focus={"$refs.filter_text.placeholder = '#{@placeholder}';"}
|
||||||
|
x-on:blur="$refs.filter_text.placeholder = 'Press / to search';"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Heroicons.backspace
|
<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"
|
class="feather ml-2 cursor-pointer hover:text-red-500 dark:text-gray-300 dark:hover:text-red-500"
|
||||||
phx-click="reset-filter-text"
|
phx-click="reset-filter-text"
|
||||||
id="reset-filter"
|
id="reset-filter"
|
||||||
|
x-ref="reset_filter"
|
||||||
/>
|
/>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ defmodule PlausibleWeb.AdminController do
|
||||||
teams_list = Plausible.Auth.UserAdmin.teams(user.owned_teams)
|
teams_list = Plausible.Auth.UserAdmin.teams(user.owned_teams)
|
||||||
|
|
||||||
html_response = """
|
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;">
|
<div style="margin-bottom: 1.1em;">
|
||||||
<p><b>Owned teams:</b></p>
|
<p><b>Owned teams:</b></p>
|
||||||
#{teams_list}
|
#{teams_list}
|
||||||
|
|
@ -186,6 +187,7 @@ defmodule PlausibleWeb.AdminController do
|
||||||
|
|
||||||
defp usage_and_limits_html(team, usage, limits, embed?) do
|
defp usage_and_limits_html(team, usage, limits, embed?) do
|
||||||
content = """
|
content = """
|
||||||
|
<a style="margin-bottom: 2em; float: right;" target="_blank" href="/cs/teams/team/#{team.id}">Open in CS</a>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Team: <b>#{html_escape(Teams.name(team))}</b></li>
|
<li>Team: <b>#{html_escape(Teams.name(team))}</b></li>
|
||||||
<li>Setup: <b>#{if(team.setup_complete, do: "Yes", else: "No")}</b></li>
|
<li>Setup: <b>#{if(team.setup_complete, do: "Yes", else: "No")}</b></li>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
defmodule PlausibleWeb.Live.Components.ComboBox do
|
defmodule PlausibleWeb.Live.Components.ComboBox do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Phoenix LiveComponent for a combobox UI element with search and selection
|
Phoenix LiveComponent for a combobox UI element with search and selection
|
||||||
functionality.
|
functionality.
|
||||||
|
|
||||||
The component allows users to select an option from a list of options,
|
The component allows users to select an option from a list of options,
|
||||||
which can be searched by typing in the input field.
|
which can be searched by typing in the input field.
|
||||||
|
|
||||||
The component renders an input field with a dropdown anchor and a
|
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
|
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
|
by default but can be customized. When a user types into the input
|
||||||
|
|
|
||||||
|
|
@ -90,19 +90,27 @@ defmodule PlausibleWeb.Live.Components.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
def input(%{type: "checkbox"} = assigns) do
|
def input(%{type: "checkbox"} = assigns) do
|
||||||
|
assigns =
|
||||||
|
assign_new(assigns, :checked, fn ->
|
||||||
|
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||||
|
end)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div class={[
|
<div class={[
|
||||||
"flex flex-inline items-center sm:justify-start justify-center gap-x-2",
|
|
||||||
@mt? && "mt-2"
|
@mt? && "mt-2"
|
||||||
]}>
|
]}>
|
||||||
<input
|
<.label for={@id} class="gap-x-2 flex flex-inline items-center sm:justify-start justify-center ">
|
||||||
type="checkbox"
|
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
|
||||||
value={@value || "true"}
|
<input
|
||||||
id={@id}
|
type="checkbox"
|
||||||
name={@name}
|
value={assigns[:value] || "true"}
|
||||||
class="block h-5 w-5 rounded dark:bg-gray-700 border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
checked={@checked}
|
||||||
/>
|
id={@id}
|
||||||
<.label for={@id}>{@label}</.label>
|
name={@name}
|
||||||
|
class="block h-5 w-5 rounded dark:bg-gray-700 border-gray-300 text-indigo-600 focus:ring-indigo-600"
|
||||||
|
/>
|
||||||
|
{@label}
|
||||||
|
</.label>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -137,6 +145,22 @@ defmodule PlausibleWeb.Live.Components.Form do
|
||||||
"""
|
"""
|
||||||
end
|
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...
|
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||||
def input(assigns) do
|
def input(assigns) do
|
||||||
errors =
|
errors =
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ defmodule PlausibleWeb.Live.Components.Team do
|
||||||
<.dropdown id={"role-dropdown-#{@user.email}"}>
|
<.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">
|
<: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()}
|
{@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>
|
</:button>
|
||||||
<:menu class="dropdown-items max-w-60">
|
<:menu class="dropdown-items max-w-60">
|
||||||
<.role_item
|
<.role_item
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ defmodule PlausibleWeb.Live.Sites do
|
||||||
~H"""
|
~H"""
|
||||||
<.flash_messages flash={@flash} />
|
<.flash_messages flash={@flash} />
|
||||||
<div
|
<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-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"
|
x-on:keydown.escape.window="invitationOpen = false"
|
||||||
class="container pt-6"
|
class="container pt-6"
|
||||||
|
|
@ -539,43 +540,7 @@ defmodule PlausibleWeb.Live.Sites do
|
||||||
|
|
||||||
def search_form(assigns) do
|
def search_form(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<form id="filter-form" phx-change="filter" action={@uri} method="GET">
|
<.filter_bar filter_text={@filter_text} placeholder="Search Sites"></.filter_bar>
|
||||||
<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>
|
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -635,13 +600,13 @@ defmodule PlausibleWeb.Live.Sites do
|
||||||
|
|
||||||
def handle_event(
|
def handle_event(
|
||||||
"filter",
|
"filter",
|
||||||
%{"filter_text" => filter_text},
|
%{"filter-text" => filter_text},
|
||||||
%{assigns: %{filter_text: filter_text}} = socket
|
%{assigns: %{filter_text: filter_text}} = socket
|
||||||
) do
|
) do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("filter", %{"filter_text" => filter_text}, socket) do
|
def handle_event("filter", %{"filter-text" => filter_text}, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> reset_pagination()
|
|> reset_pagination()
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,16 @@ defmodule PlausibleWeb.Router do
|
||||||
end
|
end
|
||||||
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
|
on_ee do
|
||||||
scope path: "/flags" do
|
scope path: "/flags" do
|
||||||
pipe_through :flags
|
pipe_through :flags
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,19 @@
|
||||||
}>
|
}>
|
||||||
<.styled_link
|
<.styled_link
|
||||||
class="text-sm mr-6"
|
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}
|
new_tab={true}
|
||||||
>
|
>
|
||||||
CRM
|
CRM
|
||||||
</.styled_link>
|
</.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>
|
||||||
<li
|
<li
|
||||||
:if={ee?() and Plausible.Teams.on_trial?(@conn.assigns[:current_team])}
|
:if={ee?() and Plausible.Teams.on_trial?(@conn.assigns[:current_team])}
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,7 @@ defmodule Plausible.HelpScoutTest do
|
||||||
stub_help_scout_requests(email)
|
stub_help_scout_requests(email)
|
||||||
team = team_of(user)
|
team = team_of(user)
|
||||||
|
|
||||||
crm_url = "#{PlausibleWeb.Endpoint.url()}/crm/teams/team/#{team.id}"
|
crm_url = "#{PlausibleWeb.Endpoint.url()}/cs/teams/team/#{team.id}"
|
||||||
|
|
||||||
owned_sites_url =
|
|
||||||
"#{PlausibleWeb.Endpoint.url()}/crm/sites/site?custom_search=#{URI.encode_www_form(team.identifier)}"
|
|
||||||
|
|
||||||
assert {:ok,
|
assert {:ok,
|
||||||
%{
|
%{
|
||||||
|
|
@ -70,8 +67,7 @@ defmodule Plausible.HelpScoutTest do
|
||||||
status_label: "Trial",
|
status_label: "Trial",
|
||||||
plan_link: "#",
|
plan_link: "#",
|
||||||
plan_label: "None",
|
plan_label: "None",
|
||||||
sites_count: 0,
|
sites_count: 0
|
||||||
sites_link: ^owned_sites_url
|
|
||||||
}} = HelpScout.get_details_for_customer("500")
|
}} = HelpScout.get_details_for_customer("500")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -408,12 +404,10 @@ defmodule Plausible.HelpScoutTest do
|
||||||
describe "get_details_for_emails/2" do
|
describe "get_details_for_emails/2" do
|
||||||
test "returns details for user and persists mapping" do
|
test "returns details for user and persists mapping" do
|
||||||
%{email: email} = user = new_user(trial_expiry_date: Date.utc_today())
|
%{email: email} = user = new_user(trial_expiry_date: Date.utc_today())
|
||||||
|
|
||||||
team = team_of(user)
|
team = team_of(user)
|
||||||
|
|
||||||
crm_url = "#{PlausibleWeb.Endpoint.url()}/crm/teams/team/#{team.id}"
|
crm_url = "#{PlausibleWeb.Endpoint.url()}/cs/teams/team/#{team.id}"
|
||||||
|
|
||||||
owned_sites_url =
|
|
||||||
"#{PlausibleWeb.Endpoint.url()}/crm/sites/site?custom_search=#{URI.encode_www_form(team.identifier)}"
|
|
||||||
|
|
||||||
assert {:ok,
|
assert {:ok,
|
||||||
%{
|
%{
|
||||||
|
|
@ -421,8 +415,7 @@ defmodule Plausible.HelpScoutTest do
|
||||||
status_label: "Trial",
|
status_label: "Trial",
|
||||||
plan_link: "#",
|
plan_link: "#",
|
||||||
plan_label: "None",
|
plan_label: "None",
|
||||||
sites_count: 0,
|
sites_count: 0
|
||||||
sites_link: ^owned_sites_url
|
|
||||||
}} = HelpScout.get_details_for_emails([email], "123")
|
}} = HelpScout.get_details_for_emails([email], "123")
|
||||||
|
|
||||||
assert {:ok, ^email} = HelpScout.lookup_mapping("123")
|
assert {:ok, ^email} = HelpScout.lookup_mapping("123")
|
||||||
|
|
@ -449,10 +442,7 @@ defmodule Plausible.HelpScoutTest do
|
||||||
new_site(owner: user2)
|
new_site(owner: user2)
|
||||||
team2 = team_of(user2)
|
team2 = team_of(user2)
|
||||||
|
|
||||||
crm_url = "#{PlausibleWeb.Endpoint.url()}/crm/teams/team/#{team2.id}"
|
crm_url = "#{PlausibleWeb.Endpoint.url()}/cs/teams/team/#{team2.id}"
|
||||||
|
|
||||||
owned_sites_url =
|
|
||||||
"#{PlausibleWeb.Endpoint.url()}/crm/sites/site?custom_search=#{URI.encode_www_form(team2.identifier)}"
|
|
||||||
|
|
||||||
assert {:ok,
|
assert {:ok,
|
||||||
%{
|
%{
|
||||||
|
|
@ -460,8 +450,7 @@ defmodule Plausible.HelpScoutTest do
|
||||||
status_label: "Trial",
|
status_label: "Trial",
|
||||||
plan_link: "#",
|
plan_link: "#",
|
||||||
plan_label: "None",
|
plan_label: "None",
|
||||||
sites_count: 2,
|
sites_count: 2
|
||||||
sites_link: ^owned_sites_url
|
|
||||||
}} = HelpScout.get_details_for_emails([user1.email, user2.email], "123")
|
}} = HelpScout.get_details_for_emails([user1.email, user2.email], "123")
|
||||||
|
|
||||||
user2_email = user2.email
|
user2_email = user2.email
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ defmodule PlausibleWeb.HelpScoutControllerTest do
|
||||||
"/helpscout/callback?conversation-id=123&customer-id=500&X-HelpScout-Signature=#{signature}"
|
"/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
|
end
|
||||||
|
|
||||||
test "returns error on failure", %{conn: conn} do
|
test "returns error on failure", %{conn: conn} do
|
||||||
|
|
@ -129,7 +129,7 @@ defmodule PlausibleWeb.HelpScoutControllerTest do
|
||||||
)
|
)
|
||||||
|
|
||||||
assert html = html_response(conn, 200)
|
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"
|
assert html =~ "Some note<br>\nwith new line"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -158,7 +158,7 @@ defmodule PlausibleWeb.HelpScoutControllerTest do
|
||||||
)
|
)
|
||||||
|
|
||||||
assert html = html_response(conn, 200)
|
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 =~ "Some user notes"
|
||||||
assert html =~ "My Personal Sites"
|
assert html =~ "My Personal Sites"
|
||||||
assert html =~ "HS Integration Test Team"
|
assert html =~ "HS Integration Test Team"
|
||||||
|
|
|
||||||
|
|
@ -246,7 +246,7 @@ defmodule PlausibleWeb.Live.SitesTest do
|
||||||
|
|
||||||
{:ok, lv, _html} = live(conn, "/sites")
|
{:ok, lv, _html} = live(conn, "/sites")
|
||||||
|
|
||||||
type_into_input(lv, "filter_text", "firs")
|
type_into_input(lv, "filter-text", "firs")
|
||||||
html = render(lv)
|
html = render(lv)
|
||||||
|
|
||||||
assert html =~ "first.example.com"
|
assert html =~ "first.example.com"
|
||||||
|
|
@ -267,7 +267,7 @@ defmodule PlausibleWeb.Live.SitesTest do
|
||||||
assert html =~ "page=2"
|
assert html =~ "page=2"
|
||||||
refute html =~ "page=1"
|
refute html =~ "page=1"
|
||||||
|
|
||||||
type_into_input(lv, "filter_text", "anot")
|
type_into_input(lv, "filter-text", "anot")
|
||||||
html = render(lv)
|
html = render(lv)
|
||||||
|
|
||||||
assert html =~ "first.another.example.com"
|
assert html =~ "first.another.example.com"
|
||||||
|
|
@ -355,7 +355,7 @@ defmodule PlausibleWeb.Live.SitesTest do
|
||||||
|
|
||||||
defp get_invitation_data(html) do
|
defp get_invitation_data(html) do
|
||||||
html
|
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.trim("dropdown")
|
||||||
|> String.replace("selectedInvitation:", "\"selectedInvitation\":")
|
|> String.replace("selectedInvitation:", "\"selectedInvitation\":")
|
||||||
|> String.replace("invitationOpen:", "\"invitationOpen\":")
|
|> String.replace("invitationOpen:", "\"invitationOpen\":")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue