berrypod/lib/berrypod_web/live/setup/onboarding.ex

597 lines
18 KiB
Elixir
Raw Normal View History

defmodule BerrypodWeb.Setup.Onboarding do
use BerrypodWeb, :live_view
alias Berrypod.{Accounts, Products, Settings, Setup}
alias Berrypod.Products.ProviderConnection
alias Berrypod.Providers.Provider
alias Berrypod.Stripe.Setup, as: StripeSetup
# ── Mount ──
@impl true
def mount(_params, _session, socket) do
setup = Setup.setup_status()
cond do
setup.site_live ->
{:ok, push_navigate(socket, to: ~p"/")}
setup.setup_complete ->
{:ok, push_navigate(socket, to: ~p"/admin")}
setup.admin_created and is_nil(get_user(socket)) ->
{:ok, mount_check_inbox(socket)}
setup.admin_created ->
{:ok, mount_configure(socket, setup)}
true ->
{:ok, mount_email_form(socket)}
end
end
defp get_user(socket) do
case socket.assigns do
%{current_scope: %{user: user}} when not is_nil(user) -> user
_ -> nil
end
end
defp mount_email_form(socket) do
socket
|> assign(:page_title, "Set up your shop")
|> assign(:phase, :email_form)
|> assign(:account_form, to_form(%{"email" => ""}, as: :account))
end
defp mount_check_inbox(socket) do
socket
|> assign(:page_title, "Set up your shop")
|> assign(:phase, :check_inbox)
|> assign(:admin_email, Accounts.admin_email())
|> assign(:local_mail?, local_mail_adapter?())
end
defp mount_configure(socket, setup) do
provider_conn = Products.get_first_provider_connection()
socket
|> assign(:page_title, "Set up your shop")
|> assign(:phase, :configure)
|> assign(:setup, setup)
# Provider
|> assign(:providers, Provider.all())
|> assign(:selected_provider, nil)
|> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider))
|> assign(:provider_testing, false)
|> assign(:provider_test_result, nil)
|> assign(:provider_connecting, false)
|> assign(:provider_conn, provider_conn)
|> assign(:pending_provider_key, nil)
# Stripe
|> assign(:stripe_form, to_form(%{"api_key" => ""}, as: :stripe))
|> assign(:stripe_connecting, false)
end
# ── Events: Account ──
@impl true
def handle_event("create_account", %{"account" => %{"email" => email}}, socket) do
if email == "" do
{:noreply, put_flash(socket, :error, "Please enter your email address")}
else
case Accounts.register_user(%{email: email}) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_login_instructions(
user,
&url(~p"/users/log-in/#{&1}")
)
{:noreply,
socket
|> assign(:phase, :check_inbox)
|> assign(:admin_email, user.email)
|> assign(:local_mail?, local_mail_adapter?())}
{:error, changeset} ->
{:noreply,
socket
|> assign(:account_form, to_form(changeset, as: :account))
|> put_flash(:error, "Could not create account")}
end
end
end
def handle_event("start_over", _params, socket) do
case Accounts.get_unconfirmed_admin() do
%Accounts.User{} = user ->
case Accounts.delete_unconfirmed_user(user) do
{:ok, _} ->
{:noreply,
socket
|> assign(:phase, :email_form)
|> assign(:account_form, to_form(%{"email" => ""}, as: :account))
|> clear_flash()}
{:error, :already_confirmed} ->
{:noreply, push_navigate(socket, to: ~p"/setup")}
end
nil ->
{:noreply, push_navigate(socket, to: ~p"/setup")}
end
end
# ── Events: Provider ──
def handle_event("select_provider", %{"type" => type}, socket) do
{:noreply,
socket
|> assign(:selected_provider, type)
|> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider))
|> assign(:provider_test_result, nil)
|> assign(:pending_provider_key, nil)}
end
def handle_event("validate_provider", %{"provider" => params}, socket) do
{:noreply, assign(socket, pending_provider_key: params["api_key"])}
end
def handle_event("test_provider", _params, socket) do
type = socket.assigns.selected_provider
api_key = socket.assigns.pending_provider_key
if api_key in [nil, ""] do
{:noreply, assign(socket, provider_test_result: {:error, :no_api_key})}
else
socket = assign(socket, provider_testing: true, provider_test_result: nil)
temp_conn = %ProviderConnection{
provider_type: type,
api_key_encrypted: encrypt_api_key(api_key)
}
result = Berrypod.Providers.test_connection(temp_conn)
{:noreply, assign(socket, provider_testing: false, provider_test_result: result)}
end
end
def handle_event("connect_provider", %{"provider" => %{"api_key" => api_key}}, socket) do
type = socket.assigns.selected_provider
if api_key == "" do
{:noreply, put_flash(socket, :error, "Please enter your API token")}
else
socket = assign(socket, provider_connecting: true)
params =
%{"api_key" => api_key, "provider_type" => type}
|> maybe_add_shop_config(socket.assigns.provider_test_result)
|> maybe_add_name(socket.assigns.provider_test_result, type)
case Products.create_provider_connection(params) do
{:ok, connection} ->
Products.enqueue_sync(connection)
setup = %{
socket.assigns.setup
| provider_connected: true,
provider_type: type
}
{:noreply,
socket
|> assign(:provider_connecting, false)
|> assign(:provider_conn, connection)
|> assign(:setup, setup)
|> put_flash(:info, "Connected! Product sync started in the background.")}
{:error, _changeset} ->
{:noreply,
socket
|> assign(:provider_connecting, false)
|> put_flash(:error, "Failed to save connection")}
end
end
end
# ── Events: Stripe ──
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
if api_key == "" do
{:noreply, put_flash(socket, :error, "Please enter your Stripe secret key")}
else
socket = assign(socket, stripe_connecting: true)
case StripeSetup.connect(api_key) do
{:ok, _result} ->
setup = %{socket.assigns.setup | stripe_connected: true}
setup =
if setup.admin_created and setup.provider_connected do
%{setup | setup_complete: true}
else
setup
end
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> assign(:setup, setup)
|> put_flash(:info, "Stripe connected")}
{:error, message} ->
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> put_flash(:error, "Stripe connection failed: #{message}")}
end
end
end
# ── Render ──
@impl true
def render(assigns) do
~H"""
<div class="setup-page">
<div class="setup-header">
<h1 class="setup-title">Set up your shop</h1>
<p :if={@phase == :email_form} class="setup-subtitle">
Enter your email to create the admin account.
</p>
<p :if={@phase == :check_inbox} class="setup-subtitle">
Almost there check your inbox.
</p>
<p :if={@phase == :configure} class="setup-subtitle">
Connect your accounts to get going.
</p>
</div>
<%!-- Phase 1: email form --%>
<div :if={@phase == :email_form} class="setup-sections">
<.section_card title="Create admin account" number={1} done={false}>
<p class="setup-hint">We'll send a login link to get you started.</p>
<.form for={@account_form} phx-submit="create_account">
<.input
field={@account_form[:email]}
type="email"
label="Email address"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<div class="setup-actions">
<.button phx-disable-with="Creating account...">Create account</.button>
</div>
</.form>
</.section_card>
</div>
<%!-- Phase 2: check inbox --%>
<div :if={@phase == :check_inbox} class="setup-sections">
<div class="setup-card">
<div class="setup-check-inbox">
<.icon name="hero-envelope" class="size-10 setup-inbox-icon" />
<h2 class="setup-inbox-heading">Check your inbox</h2>
<p class="setup-inbox-detail">
We sent a login link to <strong>{@admin_email}</strong>.
Click it to continue setting up your shop.
</p>
<div :if={@local_mail?} class="admin-alert admin-alert-info">
<.icon name="hero-information-circle" class="size-5 shrink-0" />
<div>
<p>
Using local mail adapter.
See sent emails at <a href="/dev/mailbox" class="underline">/dev/mailbox</a>.
</p>
</div>
</div>
<p class="setup-start-over">
Wrong email?
<button type="button" phx-click="start_over" class="setup-link">
Start over
</button>
</p>
</div>
</div>
</div>
<%!-- Phase 3: configure (logged in) --%>
<div :if={@phase == :configure} class="setup-sections">
<.section_card
title="Create admin account"
number={1}
done={true}
summary={account_summary(assigns)}
>
<span />
</.section_card>
<.section_card
title="Connect a print provider"
number={2}
done={@setup.provider_connected}
summary={provider_summary(assigns)}
>
<.provider_section
providers={@providers}
selected={@selected_provider}
form={@provider_form}
testing={@provider_testing}
test_result={@provider_test_result}
connecting={@provider_connecting}
/>
</.section_card>
<.section_card
title="Connect payments"
number={3}
done={@setup.stripe_connected}
summary={stripe_summary(assigns)}
>
<.stripe_section
form={@stripe_form}
connecting={@stripe_connecting}
/>
</.section_card>
</div>
<%!-- All done --%>
<div :if={@phase == :configure and @setup.setup_complete} class="setup-complete">
<.icon name="hero-check-badge" class="setup-complete-icon" />
<h2>You're all set</h2>
<p>Head to the dashboard to sync products, customise your theme, and go live.</p>
<.link navigate={~p"/admin"} class="admin-btn admin-btn-primary">
Go to dashboard <span aria-hidden="true">&rarr;</span>
</.link>
</div>
</div>
"""
end
# ── Section card component ──
attr :title, :string, required: true
attr :number, :integer, required: true
attr :done, :boolean, required: true
attr :summary, :string, default: nil
slot :inner_block, required: true
defp section_card(assigns) do
~H"""
<div class={["setup-card", @done && "setup-card-done"]}>
<div class="setup-card-header">
<span class={["setup-card-number", @done && "setup-card-number-done"]}>
<%= if @done do %>
<.icon name="hero-check-mini" class="size-4" />
<% else %>
{@number}
<% end %>
</span>
<h2 class="setup-card-title">{@title}</h2>
</div>
<%= if @done and @summary do %>
<p class="setup-card-summary">{@summary}</p>
<% else %>
<div :if={!@done} class="setup-card-body">
{render_slot(@inner_block)}
</div>
<% end %>
</div>
"""
end
# ── Provider section ──
attr :providers, :list, required: true
attr :selected, :string, default: nil
attr :form, :any, required: true
attr :testing, :boolean, required: true
attr :test_result, :any, default: nil
attr :connecting, :boolean, required: true
defp provider_section(assigns) do
~H"""
<div>
<p class="setup-hint">Choose a print-on-demand provider and connect your API key.</p>
<div class="setup-provider-grid">
<button
:for={provider <- @providers}
type="button"
phx-click={provider.status == :available && "select_provider"}
phx-value-type={provider.type}
disabled={provider.status == :coming_soon}
class={[
"setup-provider-card",
@selected == provider.type && "setup-provider-card-selected",
provider.status == :coming_soon && "setup-provider-card-disabled"
]}
>
<span class="setup-provider-name">{provider.name}</span>
<span class="setup-provider-tagline">{provider.tagline}</span>
<span :if={provider.status == :coming_soon} class="setup-provider-badge">
Coming soon
</span>
</button>
</div>
<%!-- API key form for selected provider --%>
<div :if={@selected} class="setup-provider-form">
<% provider_info = Enum.find(@providers, &(&1.type == @selected)) %>
<p class="setup-hint">
{provider_info.setup_hint}.
<a href={provider_info.setup_url} target="_blank" rel="noopener" class="setup-link">
Open {provider_info.name} &rarr;
</a>
</p>
<.form for={@form} phx-change="validate_provider" phx-submit="connect_provider">
<.input
field={@form[:api_key]}
type="password"
label="API token"
placeholder="Paste your token here"
autocomplete="off"
/>
<div class="setup-actions">
<button
type="button"
phx-click="test_provider"
disabled={@testing}
class="admin-btn admin-btn-secondary"
>
<%= if @testing do %>
<.icon name="hero-arrow-path" class="size-4 animate-spin" /> Checking...
<% else %>
<.icon name="hero-signal" class="size-4" /> Check connection
<% end %>
</button>
<.button type="submit" disabled={@connecting or @testing}>
{if @connecting, do: "Connecting...", else: "Connect"}
</.button>
</div>
<.provider_test_feedback :if={@test_result} result={@test_result} />
</.form>
</div>
</div>
"""
end
attr :result, :any, required: true
defp provider_test_feedback(assigns) do
~H"""
<div class="setup-test-result">
<%= case @result do %>
<% {:ok, info} -> %>
<span class="setup-test-ok">
<.icon name="hero-check-circle" class="size-4" />
Connected{if info[:shop_name], do: " to #{info.shop_name}", else: ""}
</span>
<% {:error, :no_api_key} -> %>
<span class="setup-test-error">
<.icon name="hero-x-circle" class="size-4" /> Please enter your API token
</span>
<% {:error, reason} -> %>
<span class="setup-test-error">
<.icon name="hero-x-circle" class="size-4" /> {format_error(reason)}
</span>
<% end %>
</div>
"""
end
# ── Stripe section ──
attr :form, :any, required: true
attr :connecting, :boolean, required: true
defp stripe_section(assigns) do
~H"""
<div>
<p class="setup-hint">
Enter your Stripe secret key to accept payments.
<a
href="https://dashboard.stripe.com/apikeys"
target="_blank"
rel="noopener"
class="setup-link"
>
Open Stripe dashboard &rarr;
</a>
</p>
<.form for={@form} phx-submit="connect_stripe">
<.input
field={@form[:api_key]}
type="password"
label="Secret key"
autocomplete="off"
placeholder="sk_test_... or sk_live_..."
/>
<p class="setup-key-hint">
Starts with <code>sk_test_</code> or <code>sk_live_</code>. Encrypted at rest.
</p>
<div class="setup-actions">
<.button phx-disable-with="Connecting...">
{if @connecting, do: "Connecting...", else: "Connect Stripe"}
</.button>
</div>
</.form>
</div>
"""
end
# ── Helpers ──
defp account_summary(%{current_scope: %{user: user}}) when not is_nil(user) do
user.email
end
defp account_summary(_), do: "Account created"
defp provider_summary(%{setup: %{provider_type: type}}) when is_binary(type) do
case Provider.get(type) do
nil -> "Connected"
info -> "Connected to #{info.name}"
end
end
defp provider_summary(_), do: nil
defp stripe_summary(%{setup: %{stripe_connected: true}}) do
case Settings.secret_hint("stripe_api_key") do
nil -> "Connected"
hint -> "Connected · #{hint}"
end
end
defp stripe_summary(_), do: nil
defp encrypt_api_key(api_key) do
case Berrypod.Vault.encrypt(api_key) do
{:ok, encrypted} -> encrypted
_ -> nil
end
end
defp maybe_add_shop_config(params, {:ok, %{shop_id: shop_id}}) do
config = Map.get(params, "config", %{}) |> Map.put("shop_id", to_string(shop_id))
Map.put(params, "config", config)
end
defp maybe_add_shop_config(params, _), do: params
defp maybe_add_name(params, {:ok, %{shop_name: name}}, _type) when is_binary(name) do
Map.put_new(params, "name", name)
end
defp maybe_add_name(params, _, type) do
case Provider.get(type) do
nil -> Map.put_new(params, "name", type)
info -> Map.put_new(params, "name", info.name)
end
end
defp format_error(:unauthorized), do: "That token doesn't seem to be valid"
defp format_error(:timeout), do: "Couldn't reach the provider — try again"
defp format_error(:provider_not_implemented), do: "This provider isn't supported yet"
defp format_error({:http_error, _code}), do: "Something went wrong — try again"
defp format_error(error) when is_binary(error), do: error
defp format_error(_), do: "Connection failed — check your token and try again"
defp local_mail_adapter? do
Application.get_env(:berrypod, Berrypod.Mailer)[:adapter] == Swoosh.Adapters.Local
end
end