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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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