diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70f9aba546..30bd0c8bfd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,8 @@ and this project adheres to
### Added
+- Allow users to book for demo sessions
+ [PR#3035](https://github.com/OpenFn/lightning/pull/3035)
- Allow workflow and project concurrency progress windows
[#2995](https://github.com/OpenFn/lightning/issues/2995)
diff --git a/lib/lightning/config.ex b/lib/lightning/config.ex
index 8ad37def88..dd20fe4fa7 100644
--- a/lib/lightning/config.ex
+++ b/lib/lightning/config.ex
@@ -265,6 +265,25 @@ defmodule Lightning.Config do
def credential_transfer_token_validity_in_days do
2
end
+
+ @impl true
+ def book_demo_banner_enabled? do
+ Keyword.get(book_demo_banner_config(), :enabled, false)
+ end
+
+ @impl true
+ def book_demo_calendly_url do
+ Keyword.get(book_demo_banner_config(), :calendly_url)
+ end
+
+ @impl true
+ def book_demo_openfn_workflow_url do
+ Keyword.get(book_demo_banner_config(), :openfn_workflow_url)
+ end
+
+ defp book_demo_banner_config do
+ Application.get_env(:lightning, :book_demo_banner, [])
+ end
end
@callback apollo(key :: atom() | nil) :: map()
@@ -306,6 +325,9 @@ defmodule Lightning.Config do
@callback worker_token_signer() :: Joken.Signer.t()
@callback adaptor_registry() :: Keyword.t()
@callback credential_transfer_token_validity_in_days() :: integer()
+ @callback book_demo_banner_enabled?() :: boolean()
+ @callback book_demo_calendly_url() :: String.t()
+ @callback book_demo_openfn_workflow_url() :: String.t()
@doc """
Returns the configuration for the `Lightning.AdaptorRegistry` service
@@ -484,6 +506,18 @@ defmodule Lightning.Config do
impl().credential_transfer_token_validity_in_days()
end
+ def book_demo_banner_enabled? do
+ impl().book_demo_banner_enabled?()
+ end
+
+ def book_demo_calendly_url do
+ impl().book_demo_calendly_url()
+ end
+
+ def book_demo_openfn_workflow_url do
+ impl().book_demo_openfn_workflow_url()
+ end
+
defp impl do
Application.get_env(:lightning, __MODULE__, API)
end
diff --git a/lib/lightning/config/bootstrap.ex b/lib/lightning/config/bootstrap.ex
index 524fcbf885..d70f9ed627 100644
--- a/lib/lightning/config/bootstrap.ex
+++ b/lib/lightning/config/bootstrap.ex
@@ -634,6 +634,11 @@ defmodule Lightning.Config.Bootstrap do
config :lightning, :ui_metrics_tracking,
enabled: env!("UI_METRICS_ENABLED", &Utils.ensure_boolean/1, false)
+ config :lightning, :book_demo_banner,
+ enabled: false,
+ calendly_url: nil,
+ openfn_workflow_url: nil
+
# # ==============================================================================
setup_storage()
diff --git a/lib/lightning_web/init_assigns.ex b/lib/lightning_web/init_assigns.ex
index 38751186e4..95cb15d15c 100644
--- a/lib/lightning_web/init_assigns.ex
+++ b/lib/lightning_web/init_assigns.ex
@@ -16,6 +16,15 @@ defmodule LightningWeb.InitAssigns do
end)
|> assign_new(:account_confirmation_required?, fn ->
confirmation_required?
+ end)
+ |> assign_new(:banner, fn ->
+ if Lightning.Config.book_demo_banner_enabled?() and
+ is_nil(current_user.preferences["demo_banner.dismissed_at"]) do
+ %{
+ function: &LightningWeb.LiveHelpers.book_demo_banner/1,
+ attrs: %{current_user: current_user}
+ }
+ end
end)}
end
end
diff --git a/lib/lightning_web/live/book_demo_banner.ex b/lib/lightning_web/live/book_demo_banner.ex
new file mode 100644
index 0000000000..1389915eb9
--- /dev/null
+++ b/lib/lightning_web/live/book_demo_banner.ex
@@ -0,0 +1,196 @@
+defmodule LightningWeb.BookDemoBanner do
+ @moduledoc false
+ use LightningWeb, :live_component
+ alias Lightning.Accounts
+ alias Phoenix.LiveView.JS
+
+ @impl true
+ def update(%{current_user: user} = assigns, socket) do
+ {:ok,
+ socket
+ |> assign(changeset: form_changeset(user, %{}))
+ |> assign(assigns)}
+ end
+
+ defp form_changeset(user, params) do
+ data = %{
+ name: "#{user.first_name} #{user.last_name}",
+ email: user.email,
+ message: nil
+ }
+
+ types = %{name: :string, email: :string, message: :string}
+
+ {data, types}
+ |> Ecto.Changeset.cast(params, Map.keys(types))
+ |> Ecto.Changeset.validate_required(Map.keys(types))
+ end
+
+ defp dismiss_banner(current_user) do
+ timestamp = DateTime.utc_now() |> DateTime.to_unix()
+
+ Accounts.update_user_preference(
+ current_user,
+ "demo_banner.dismissed_at",
+ timestamp
+ )
+ end
+
+ @impl true
+ def handle_event("dismiss_banner", _params, socket) do
+ {:ok, _} = dismiss_banner(socket.assigns.current_user)
+
+ {:noreply, socket}
+ end
+
+ def handle_event("validate", %{"demo" => params}, socket) do
+ changeset =
+ socket.assigns.current_user
+ |> form_changeset(params)
+ |> Map.put(:action, :validate)
+
+ {:noreply, assign(socket, changeset: changeset)}
+ end
+
+ def handle_event("schedule-call", %{"demo" => params}, socket) do
+ changeset =
+ socket.assigns.current_user
+ |> form_changeset(params)
+ |> Map.put(:action, :validate)
+
+ if changeset.valid? do
+ workflow_url = Lightning.Config.book_demo_openfn_workflow_url()
+ calendly_url = Lightning.Config.book_demo_calendly_url()
+
+ redirect_url =
+ calendly_url
+ |> URI.parse()
+ |> URI.append_query(URI.encode_query(params))
+ |> URI.to_string()
+
+ {:ok, %{status: 200}} = Tesla.post(workflow_url, params)
+ dismiss_banner(socket.assigns.current_user)
+ {:noreply, redirect(socket, external: redirect_url)}
+ else
+ {:noreply, assign(socket, changeset: changeset)}
+ end
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+
+
+
+ JS.hide(to: "##{@id}")}
+ >
+ Dismiss
+ <.icon name="hero-x-mark" class="size-5 text-gray-900" />
+
+
+
+ <.modal
+ id={"#{@id}-modal"}
+ show={false}
+ close_on_keydown={false}
+ close_on_click_away={false}
+ width="w-2/5"
+ >
+ <:title>
+
+ Schedule a 1:1 automation session
+
+ Close
+ <.icon name="hero-x-mark" class="h-5 w-5 stroke-current" />
+
+
+
+ <.form
+ :let={f}
+ as={:demo}
+ id="book-demo-form"
+ for={@changeset}
+ phx-target={@myself}
+ phx-change="validate"
+ phx-submit="schedule-call"
+ >
+
+ <.input type="text" field={f[:name]} label="Name" required={true} />
+ <.input type="text" field={f[:email]} label="Email" required={true} />
+ <.input
+ type="textarea"
+ field={f[:message]}
+ label="What problem are you trying to solve with OpenFn?
+ What specific task, process, or program would you like to automate?"
+ placeholder="E.g. Every time a new person is registered in my clinic system, I must initiate a mobile money payment to a caregiver. This takes time & money. I'd like to use OpenFn to automate the process."
+ rows="5"
+ required={true}
+ />
+
+
+
+ Schedule a call
+
+
+ Cancel
+
+
+
+
+
+
+
+ """
+ end
+end
diff --git a/lib/lightning_web/live/credential_live/index.html.heex b/lib/lightning_web/live/credential_live/index.html.heex
index d34ddf742f..fd097d0eb6 100644
--- a/lib/lightning_web/live/credential_live/index.html.heex
+++ b/lib/lightning_web/live/credential_live/index.html.heex
@@ -1,4 +1,11 @@
+ <:banner>
+
+
<:header>
<:title>{@page_title}
diff --git a/lib/lightning_web/live/dashboard_live/index.ex b/lib/lightning_web/live/dashboard_live/index.ex
index 713b1b1fdc..28d72bcdc7 100644
--- a/lib/lightning_web/live/dashboard_live/index.ex
+++ b/lib/lightning_web/live/dashboard_live/index.ex
@@ -15,6 +15,13 @@ defmodule LightningWeb.DashboardLive.Index do
def render(assigns) do
~H"""
+ <:banner>
+
+
<:header>
<:title>{@page_title}
diff --git a/lib/lightning_web/live/live_helpers.ex b/lib/lightning_web/live/live_helpers.ex
index 6b74e6c10e..2e9719379a 100644
--- a/lib/lightning_web/live/live_helpers.ex
+++ b/lib/lightning_web/live/live_helpers.ex
@@ -148,6 +148,20 @@ defmodule LightningWeb.LiveHelpers do
"""
end
+ attr :current_user, Lightning.Accounts.User, required: true
+
+ def book_demo_banner(assigns) do
+ ~H"""
+
+ <.live_component
+ id="book-demo-banner"
+ module={LightningWeb.BookDemoBanner}
+ current_user={@current_user}
+ />
+
+ """
+ end
+
@spec display_short_uuid(binary()) :: binary()
def display_short_uuid(uuid_string) do
uuid_string |> String.slice(0..7)
diff --git a/lib/lightning_web/live/profile_live/edit.html.heex b/lib/lightning_web/live/profile_live/edit.html.heex
index 973176cc57..d46ef1ae9a 100644
--- a/lib/lightning_web/live/profile_live/edit.html.heex
+++ b/lib/lightning_web/live/profile_live/edit.html.heex
@@ -1,4 +1,11 @@
+ <:banner>
+
+
<:header>
<:title>{@page_title}
diff --git a/lib/lightning_web/live/project_live/settings.html.heex b/lib/lightning_web/live/project_live/settings.html.heex
index 7733e852d9..b87b9391b3 100644
--- a/lib/lightning_web/live/project_live/settings.html.heex
+++ b/lib/lightning_web/live/project_live/settings.html.heex
@@ -1,12 +1,10 @@
<:banner>
- <%= if assigns[:banner] do %>
- {Phoenix.LiveView.TagEngine.component(
- @banner.function,
- @banner.attrs,
- {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line}
- )}
- <% end %>
+
<:header>
diff --git a/lib/lightning_web/live/run_live/index.html.heex b/lib/lightning_web/live/run_live/index.html.heex
index f5fac60627..785b1ad2df 100644
--- a/lib/lightning_web/live/run_live/index.html.heex
+++ b/lib/lightning_web/live/run_live/index.html.heex
@@ -1,12 +1,10 @@
<:banner>
- <%= if assigns[:banner] do %>
- {Phoenix.LiveView.TagEngine.component(
- @banner.function,
- @banner.attrs,
- {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line}
- )}
- <% end %>
+
<:header>
@@ -564,7 +562,7 @@
type="submit"
form="run-search-form"
class="rounded-md bg-indigo-600 px-5 py-2 text-sm font-semibold
- text-white shadow-xs hover:bg-indigo-500
+ text-white shadow-xs hover:bg-indigo-500
focus-visible:outline-2 focus-visible:outline-offset-2
focus-visible:outline-indigo-600 truncate"
>
diff --git a/lib/lightning_web/live/tokens_live/index.html.heex b/lib/lightning_web/live/tokens_live/index.html.heex
index a2dc8676de..1a3f18f0c2 100644
--- a/lib/lightning_web/live/tokens_live/index.html.heex
+++ b/lib/lightning_web/live/tokens_live/index.html.heex
@@ -1,4 +1,11 @@
+ <:banner>
+
+
<:header>
<:title>{@page_title}
diff --git a/lib/lightning_web/live/workflow_live/index.ex b/lib/lightning_web/live/workflow_live/index.ex
index 5db4bf47cc..08c2017037 100644
--- a/lib/lightning_web/live/workflow_live/index.ex
+++ b/lib/lightning_web/live/workflow_live/index.ex
@@ -14,7 +14,7 @@ defmodule LightningWeb.WorkflowLive.Index do
alias LightningWeb.WorkflowLive.Helpers
alias LightningWeb.WorkflowLive.NewWorkflowForm
- alias Phoenix.LiveView.TagEngine
+ # alias Phoenix.LiveView.TagEngine
on_mount {LightningWeb.Hooks, :project_scope}
@@ -35,13 +35,11 @@ defmodule LightningWeb.WorkflowLive.Index do
~H"""
<:banner>
- <%= if assigns[:banner] do %>
- {TagEngine.component(
- @banner.function,
- @banner.attrs,
- {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line}
- )}
- <% end %>
+
<:header>
diff --git a/test/lightning_web/live/book_demo_banner_test.exs b/test/lightning_web/live/book_demo_banner_test.exs
new file mode 100644
index 0000000000..977116cb68
--- /dev/null
+++ b/test/lightning_web/live/book_demo_banner_test.exs
@@ -0,0 +1,169 @@
+defmodule LightningWeb.BookDemoBannerTest do
+ use LightningWeb.ConnCase, async: true
+
+ import Lightning.Factories
+ import Mox
+ import Phoenix.LiveViewTest
+
+ setup :verify_on_exit!
+ setup :register_and_log_in_user
+ setup :create_project_for_current_user
+
+ test "by default the banner is not shown", %{conn: conn, project: project} do
+ for route <- routes_with_banner(project) do
+ {:ok, view, html} = live(conn, route)
+ refute html =~ "What problem are you trying to solve with OpenFn?"
+ refute has_element?(view, "#book-demo-banner")
+ end
+ end
+
+ test "the banner is shown when enabled in the config", %{
+ conn: conn,
+ project: project
+ } do
+ stub(Lightning.MockConfig, :book_demo_banner_enabled?, fn ->
+ true
+ end)
+
+ for route <- routes_with_banner(project) do
+ {:ok, view, html} = live(conn, route)
+ assert has_element?(view, "#book-demo-banner")
+ assert html =~ "What problem are you trying to solve with OpenFn?"
+ end
+
+ # banner is not shown in the workflow edit page
+ workflow = insert(:simple_workflow, project: project)
+ Lightning.Workflows.Snapshot.create(workflow)
+ {:ok, view, html} = live(conn, ~p"/projects/#{project.id}/w/#{workflow.id}")
+ refute has_element?(view, "#book-demo-banner")
+ refute html =~ "What problem are you trying to solve with OpenFn?"
+ end
+
+ test "the banner is not shown when the user has already dismissed it", %{
+ conn: conn,
+ project: project,
+ user: user
+ } do
+ stub(Lightning.MockConfig, :book_demo_banner_enabled?, fn ->
+ true
+ end)
+
+ user
+ |> Ecto.Changeset.change(%{
+ preferences: %{"demo_banner.dismissed_at" => 12_345_678}
+ })
+ |> Lightning.Repo.update!()
+
+ for route <- routes_with_banner(project) do
+ {:ok, view, html} = live(conn, route)
+ refute has_element?(view, "#book-demo-banner")
+ refute html =~ "What problem are you trying to solve with OpenFn?"
+ end
+ end
+
+ test "user can dismiss the banner", %{
+ conn: conn,
+ project: project,
+ user: user
+ } do
+ stub(Lightning.MockConfig, :book_demo_banner_enabled?, fn ->
+ true
+ end)
+
+ refute user.preferences["demo_banner.dismissed_at"]
+
+ route = routes_with_banner(project) |> hd()
+
+ {:ok, view, _html} = live(conn, route)
+ assert has_element?(view, "#book-demo-banner")
+ assert has_element?(view, "#dismiss-book-demo-banner")
+
+ view |> element("#dismiss-book-demo-banner") |> render_click()
+
+ updated_user = Lightning.Repo.reload(user)
+ assert updated_user.preferences["demo_banner.dismissed_at"]
+ end
+
+ test "user can successfully book for demo", %{
+ conn: conn,
+ project: project,
+ user: user
+ } do
+ stub(Lightning.MockConfig, :book_demo_banner_enabled?, fn ->
+ true
+ end)
+
+ workflow_url = "http://localhost:4001/i/1234"
+ calendly_url = "https://calendly.com"
+
+ stub(Lightning.MockConfig, :book_demo_calendly_url, fn ->
+ calendly_url
+ end)
+
+ stub(Lightning.MockConfig, :book_demo_openfn_workflow_url, fn ->
+ workflow_url
+ end)
+
+ expected_message = "Hello world"
+
+ expected_body =
+ %{
+ "name" => "#{user.first_name} #{user.last_name}",
+ "email" => user.email,
+ "message" => expected_message
+ }
+
+ expected_redirect_url =
+ calendly_url
+ |> URI.parse()
+ |> URI.append_query(URI.encode_query(expected_body))
+ |> URI.to_string()
+
+ expect(
+ Lightning.Tesla.Mock,
+ :call,
+ fn %{method: :post, url: ^workflow_url, body: ^expected_body}, _opts ->
+ {:ok, %Tesla.Env{status: 200}}
+ end
+ )
+
+ route = routes_with_banner(project) |> hd()
+
+ {:ok, view, _html} = live(conn, route)
+ assert has_element?(view, "#book-demo-banner")
+ assert has_element?(view, "#book-demo-banner-modal")
+
+ form = view |> form("#book-demo-banner-modal form")
+
+ assert render_change(form, demo: %{email: nil, message: expected_message}) =~
+ "This field can't be blank"
+
+ assert render_submit(form) =~ "This field can't be blank"
+
+ assert {:error, {:redirect, %{to: redirect_to}}} =
+ render_submit(form,
+ demo: %{email: user.email, message: expected_message}
+ )
+
+ assert redirect_to == expected_redirect_url
+ end
+
+ defp routes_with_banner(project) do
+ [
+ # projects page
+ ~p"/projects",
+ # workflows page
+ ~p"/projects/#{project.id}/w",
+ # history page
+ ~p"/projects/#{project.id}/history",
+ # settings page
+ ~p"/projects/#{project.id}/settings",
+ # profile page
+ ~p"/profile",
+ # personal access tokens page
+ ~p"/profile/tokens",
+ # credentials page
+ ~p"/credentials"
+ ]
+ end
+end