diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js index 5b8e0a0a0d..d87a08ff05 100644 --- a/assets/js/liveview/live_socket.js +++ b/assets/js/liveview/live_socket.js @@ -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 } diff --git a/assets/package-lock.json b/assets/package-lock.json index 6013a9eaba..69c303df35 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -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", diff --git a/assets/package.json b/assets/package.json index ad73805031..00c2a64d6e 100644 --- a/assets/package.json +++ b/assets/package.json @@ -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" diff --git a/extra/lib/plausible/customer_support/resource.ex b/extra/lib/plausible/customer_support/resource.ex new file mode 100644 index 0000000000..04b368cb08 --- /dev/null +++ b/extra/lib/plausible/customer_support/resource.ex @@ -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 diff --git a/extra/lib/plausible/customer_support/resource/site.ex b/extra/lib/plausible/customer_support/resource/site.ex new file mode 100644 index 0000000000..f7380fd28d --- /dev/null +++ b/extra/lib/plausible/customer_support/resource/site.ex @@ -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 diff --git a/extra/lib/plausible/customer_support/resource/team.ex b/extra/lib/plausible/customer_support/resource/team.ex new file mode 100644 index 0000000000..c81ef59a18 --- /dev/null +++ b/extra/lib/plausible/customer_support/resource/team.ex @@ -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 diff --git a/extra/lib/plausible/customer_support/resource/user.ex b/extra/lib/plausible/customer_support/resource/user.ex new file mode 100644 index 0000000000..618c03c594 --- /dev/null +++ b/extra/lib/plausible/customer_support/resource/user.ex @@ -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 diff --git a/extra/lib/plausible/help_scout.ex b/extra/lib/plausible/help_scout.ex index c6a1405e55..a061c39664 100644 --- a/extra/lib/plausible/help_scout.ex +++ b/extra/lib/plausible/help_scout.ex @@ -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 diff --git a/extra/lib/plausible_web/live/customer_support.ex b/extra/lib/plausible_web/live/customer_support.ex new file mode 100644 index 0000000000..501aad8b15 --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support.ex @@ -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} /> + +
+
+

+ 💬 Customer Support +

+
+ +
+ <.filter_bar filter_text={@filter_text} placeholder="Search everything"> +
+ + + + +
+ """ + 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 diff --git a/extra/lib/plausible_web/live/customer_support/live/shared.ex b/extra/lib/plausible_web/live/customer_support/live/shared.ex new file mode 100644 index 0000000000..8eaf148252 --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/live/shared.ex @@ -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" + > + + {render_slot(@inner_block)} + + + + """ + end +end diff --git a/extra/lib/plausible_web/live/customer_support/live/site.ex b/extra/lib/plausible_web/live/customer_support/live/site.ex new file mode 100644 index 0000000000..573cab482d --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/live/site.ex @@ -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""" +
+
+
+ <.favicon class="w-8" domain={@site.domain} /> +
+ +
+

+ {@site.domain} +

+

+ Timezone: {@site.timezone} +

+

+ Team: + <.styled_link patch={"/cs/teams/team/#{@site.team.id}"}> + {@site.team.name} + +

+

+ (previously: {@site.domain_changed_from}) +

+
+
+ +
+ +
+ + <.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 + + + +
+ <.table rows={@people}> + <:thead> + <.th>User + <.th>Kind + <.th>Role + + <:tbody :let={{kind, person, role}}> + <.td :if={kind == :membership}> + <.styled_link class="flex items-center" patch={"/cs/users/user/#{person.id}"}> + + {person.name} + + + + <.td :if={kind == :invitation}> +
+ + {person} +
+ + + <.td :if={kind == :membership}> + Membership + + + <.td :if={kind == :invitation}> + Invitation + + <.td>{role} + + +
+
+ """ + end + + def render_result(assigns) do + ~H""" +
+
+ <.favicon class="w-5" domain={@resource.object.domain} /> +

+ {@resource.object.domain} +

+ + + Site + +
+ +
+
+ Part of {@resource.object.team.name} +
+ owned by {@resource.object.team.owners + |> Enum.map(& &1.name) + |> Enum.join(", ")} +
+
+ """ + end + + attr :domain, :string, required: true + attr :class, :string, required: true + + def favicon(assigns) do + ~H""" + + """ + 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 diff --git a/extra/lib/plausible_web/live/customer_support/live/team.ex b/extra/lib/plausible_web/live/customer_support/live/team.ex new file mode 100644 index 0000000000..0c1b453bef --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/live/team.ex @@ -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""" +
+ +
+
+
+
+
+
+ +
+
+
+

+ {@team.name} +

+

+ Set up at {@team.setup_at} + Not set up yet +

+
+
+
+ + + <.styled_link phx-click="unlock" phx-target={@myself}>Unlock Team + + + + + <.styled_link phx-click="lock" phx-target={@myself}>Lock Team + +
+
+ <.input_with_clipboard + id="team-identifier" + name="team-identifier" + label="Team Identifier" + value={@team.identifier} + onfocus="this.value = this.value;" + /> +
+
+
+ +
+ +
+ +
+
+ + Subscription status
{subscription_status(@team)} +
+
+
+ + Subscription plan
{subscription_plan(@team)} +
+
+
+ + Grace Period
{grace_period_status(@team)} +
+
+
+ +
+

Usage

+ <.table rows={monthly_pageviews_usage(@usage.monthly_pageviews, @limits.monthly_pageviews)}> + <:thead> + <.th invisible>Cycle + <.th invisible>Dates + <.th>Total + <.th>Limit + + <:tbody :let={{cycle, date, total, limit}}> + <.td>{cycle} + <.td>{date} + <.td> + limit, do: "text-red-600"}>{number_format(total)} + + <.td>{number_format(limit)} + + + +

+

Features Used

+ + {@usage.features |> Enum.map(& &1.display_name()) |> Enum.join(", ")} + +

+ +

+ Custom Plans +

+ <.table :if={!@show_plan_form?} rows={@plans}> + <:thead> + <.th invisible>Interval + <.th>Paddle Plan ID + <.th>Limits + <.th>Features + + <:tbody :let={plan}> + <.td class="align-top"> + {plan.billing_interval} + + <.td class="align-top"> + {plan.paddle_plan_id} + + + CURRENT + + + <.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>{value} + + + + <.td class="align-top"> + {feat.display_name()}
+ + + + + <.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 :if={!@show_plan_form?} phx-click="show-plan-form" phx-target={@myself}> + New Custom Plan + + <.button + :if={@show_plan_form?} + theme="bright" + phx-click="hide-plan-form" + phx-target={@myself} + > + Cancel + +
+ +
+ <.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 + + +
+ +
+ <.table rows={@sites.entries}> + <:thead> + <.th>Domain + <.th>Previous Domain + <.th invisible>Settings + <.th invisible>Dashboard + + <:tbody :let={site}> + <.td> +
+ + <.styled_link + patch={"/cs/sites/site/#{site.id}"} + class="cursor-pointer flex block items-center" + > + {site.domain} + +
+ + <.td>{site.domain_changed_from || "--"} + <.td> + <.styled_link + new_tab={true} + href={Routes.stats_path(PlausibleWeb.Endpoint, :stats, site.domain, [])} + > + Dashboard + + + <.td> + <.styled_link + new_tab={true} + href={Routes.site_path(PlausibleWeb.Endpoint, :settings_general, site.domain, [])} + > + Settings + + + + +
+ +
+ <.table rows={Layout.sorted_for_display(@team_layout)}> + <:thead> + <.th>User + <.th>Type + <.th>Role + + <:tbody :let={{_, member}}> + <.td truncate> +
+ <.styled_link + patch={"/cs/users/user/#{member.id}"} + class="cursor-pointer flex block items-center" + > + + {member.name} <{member.email}> + +
+
+ + {member.name} <{member.email}> +
+ + <.td> + {member.type} + + <.td> + {member.role} + + + +
+
+
+ """ + end + + def render_result(assigns) do + ~H""" +
+
+
+ +
+

+ {@resource.object.name} +

+ + + Team + +
+ +
+
+ Team identifier: + {@resource.object.identifier |> String.slice(0, 8)} +
+ Owned by: {@resource.object.owners + |> Enum.map(& &1.name) + |> Enum.join(", ")} +
+
+ """ + 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} + """ + 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}) + + """ + 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 diff --git a/extra/lib/plausible_web/live/customer_support/live/user.ex b/extra/lib/plausible_web/live/customer_support/live/user.ex new file mode 100644 index 0000000000..31afe615f0 --- /dev/null +++ b/extra/lib/plausible_web/live/customer_support/live/user.ex @@ -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""" +
+
+
+
+
+ +
+
+
+

+ {@user.name} +

+

+ {@user.email} + + (previously: {@user.previous_email}) +

+
+
+
+ +
+ <.table rows={@user.team_memberships}> + <:thead> + <.th>Team + <.th>Role + + <:tbody :let={membership}> + <.td> + <.styled_link patch={"/cs/teams/team/#{membership.team.id}"}> + {membership.team.name} + + + <.td>{membership.role} + + + + <.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 + + +
+
+ """ + end + + def render_result(assigns) do + ~H""" +
+
+ +

+ {@resource.object.name} +

+ + + User + +
+ +
+
+ {@resource.object.name} <{@resource.object.email}>
+
Owns {length(@resource.object.owned_teams)} team(s) +
+
+ """ + 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 diff --git a/extra/lib/plausible_web/views/help_scout_view.ex b/extra/lib/plausible_web/views/help_scout_view.ex index 3215c76402..b0c578dac8 100644 --- a/extra/lib/plausible_web/views/help_scout_view.ex +++ b/extra/lib/plausible_web/views/help_scout_view.ex @@ -85,7 +85,7 @@ defmodule PlausibleWeb.HelpScoutView do

- Owner of {@sites_count} sites + Owner of {@sites_count} sites

diff --git a/lib/plausible/auth/user.ex b/lib/plausible/auth/user.ex index bae6c03517..e1110a5ddc 100644 --- a/lib/plausible/auth/user.ex +++ b/lib/plausible/auth/user.ex @@ -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) diff --git a/lib/plausible/billing/ecto/feature.ex b/lib/plausible/billing/ecto/feature.ex index f9464cc617..a5994eee78 100644 --- a/lib/plausible/billing/ecto/feature.ex +++ b/lib/plausible/billing/ecto/feature.ex @@ -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) diff --git a/lib/plausible/billing/ecto/limit.ex b/lib/plausible/billing/ecto/limit.ex index 21c5bedce4..71097cdc46 100644 --- a/lib/plausible/billing/ecto/limit.ex +++ b/lib/plausible/billing/ecto/limit.ex @@ -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() diff --git a/lib/plausible/billing/enterprise_plan.ex b/lib/plausible/billing/enterprise_plan.ex index 54cd4efa11..b729047f21 100644 --- a/lib/plausible/billing/enterprise_plan.ex +++ b/lib/plausible/billing/enterprise_plan.ex @@ -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 diff --git a/lib/plausible/billing/feature.ex b/lib/plausible/billing/feature.ex index 813a77841c..f60ab21d3d 100644 --- a/lib/plausible/billing/feature.ex +++ b/lib/plausible/billing/feature.ex @@ -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 diff --git a/lib/plausible/crm_extensions.ex b/lib/plausible/crm_extensions.ex index 8af88a20d4..6c306dd767 100644 --- a/lib/plausible/crm_extensions.ex +++ b/lib/plausible/crm_extensions.ex @@ -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 = '
URI.encode_www_form(domain)}" target="_blank">Open Dashboard
' + buttonDOM.innerHTML = '
URI.encode_www_form(domain)}" target="_blank">Open DashboardOpen in CS
' cardBody.prepend(buttonDOM) } })() diff --git a/lib/plausible/teams/management/layout/entry.ex b/lib/plausible/teams/management/layout/entry.ex index af6335ba19..c0637e2068 100644 --- a/lib/plausible/teams/management/layout/entry.ex +++ b/lib/plausible/teams/management/layout/entry.ex @@ -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 diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index 815578f146..517e1c659f 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -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""" -
-
-
-
+
+
+ +
@@ -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';" /> - -
+
+
{render_slot(@inner_block)}
diff --git a/lib/plausible_web/controllers/admin_controller.ex b/lib/plausible_web/controllers/admin_controller.ex index c3b2557916..0a66909d8c 100644 --- a/lib/plausible_web/controllers/admin_controller.ex +++ b/lib/plausible_web/controllers/admin_controller.ex @@ -42,6 +42,7 @@ defmodule PlausibleWeb.AdminController do teams_list = Plausible.Auth.UserAdmin.teams(user.owned_teams) html_response = """ + Open in CS

Owned teams:

#{teams_list} @@ -186,6 +187,7 @@ defmodule PlausibleWeb.AdminController do defp usage_and_limits_html(team, usage, limits, embed?) do content = """ + Open in CS
  • Team: #{html_escape(Teams.name(team))}
  • Setup: #{if(team.setup_complete, do: "Yes", else: "No")}
  • diff --git a/lib/plausible_web/live/components/combo_box.ex b/lib/plausible_web/live/components/combo_box.ex index 091dfaea20..d432f0f4ab 100644 --- a/lib/plausible_web/live/components/combo_box.ex +++ b/lib/plausible_web/live/components/combo_box.ex @@ -1,13 +1,14 @@ defmodule PlausibleWeb.Live.Components.ComboBox do @moduledoc """ 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, 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 `
    `. 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 diff --git a/lib/plausible_web/live/components/form.ex b/lib/plausible_web/live/components/form.ex index 2599ab2fd9..26d177e207 100644 --- a/lib/plausible_web/live/components/form.ex +++ b/lib/plausible_web/live/components/form.ex @@ -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"""
    - - <.label for={@id}>{@label} + <.label for={@id} class="gap-x-2 flex flex-inline items-center sm:justify-start justify-center "> + + + {@label} +
    """ end @@ -137,6 +145,22 @@ defmodule PlausibleWeb.Live.Components.Form do """ end + def input(%{type: "textarea"} = assigns) do + ~H""" +
    + <.label for={@id}>{@label} + + <.error :for={msg <- @errors}>{msg} +
    + """ + end + # All other inputs text, datetime-local, url, password, etc. are handled here... def input(assigns) do errors = diff --git a/lib/plausible_web/live/components/team.ex b/lib/plausible_web/live/components/team.ex index 002992c671..b6dd68681c 100644 --- a/lib/plausible_web/live/components/team.ex +++ b/lib/plausible_web/live/components/team.ex @@ -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()} - + <:menu class="dropdown-items max-w-60"> <.role_item diff --git a/lib/plausible_web/live/sites.ex b/lib/plausible_web/live/sites.ex index d485aa3b3c..8a4e7ee8ad 100644 --- a/lib/plausible_web/live/sites.ex +++ b/lib/plausible_web/live/sites.ex @@ -51,6 +51,7 @@ defmodule PlausibleWeb.Live.Sites do ~H""" <.flash_messages flash={@flash} />
    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""" - -
    -
    -
    - -
    - -
    - - - - <.spinner class="hidden phx-change-loading:inline ml-2" /> -
    - + <.filter_bar filter_text={@filter_text} placeholder="Search Sites"> """ 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() diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index 91a0c0169c..c37e9796fd 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -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 diff --git a/lib/plausible_web/templates/layout/_header.html.heex b/lib/plausible_web/templates/layout/_header.html.heex index eee6613f64..da20b8d3ee 100644 --- a/lib/plausible_web/templates/layout/_header.html.heex +++ b/lib/plausible_web/templates/layout/_header.html.heex @@ -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 + class="text-sm mr-6" + href={"/cs/sites/site/#{@conn.assigns.site.id}"} + new_tab={true} + > + CS +
  • \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" diff --git a/test/plausible_web/live/sites_test.exs b/test/plausible_web/live/sites_test.exs index 9bd040c37c..0acbcb6283 100644 --- a/test/plausible_web/live/sites_test.exs +++ b/test/plausible_web/live/sites_test.exs @@ -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\":")