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:
hq1 2025-05-15 10:05:32 +02:00 committed by GitHub
parent 96abac2d4e
commit c009b92fca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1626 additions and 107 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;">
&larr; 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

View File

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

View File

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

View File

@ -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} &lt;{member.email}&gt;
</.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} &lt;{member.email}&gt;
</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

View File

@ -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} &lt;{@resource.object.email}&gt; <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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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\":")