Extract Products.connect_provider/2 that tests the connection, fetches shop_id, creates the record, and enqueues sync. Both the setup wizard and the providers form now use this shared function instead of duplicating the flow. Also makes the products empty state context-aware (distinguishes "no provider" from "provider connected but no products"). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
493 lines
15 KiB
Elixir
493 lines
15 KiB
Elixir
defmodule BerrypodWeb.Setup.Onboarding do
|
|
use BerrypodWeb, :live_view
|
|
|
|
alias Berrypod.{Accounts, Products, Settings, Setup}
|
|
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, push_navigate(socket, to: ~p"/users/log-in")}
|
|
|
|
true ->
|
|
{:ok, mount_setup(socket, setup)}
|
|
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_setup(socket, setup) do
|
|
logged_in? = get_user(socket) != nil
|
|
provider_conn = Products.get_first_provider_connection()
|
|
|
|
current_step =
|
|
cond do
|
|
not logged_in? -> 1
|
|
not setup.provider_connected -> 2
|
|
true -> 3
|
|
end
|
|
|
|
socket
|
|
|> assign(:page_title, "Set up your shop")
|
|
|> assign(:setup, setup)
|
|
|> assign(:logged_in?, logged_in?)
|
|
|> assign(:current_step, current_step)
|
|
# Secret gate
|
|
|> assign(:require_secret?, Setup.require_setup_secret?())
|
|
|> assign(:secret_verified, false)
|
|
|> assign(:secret_form, to_form(%{"secret" => ""}, as: :secret))
|
|
# Account (card 1)
|
|
|> assign(:account_form, to_form(%{"email" => "", "shop_name" => ""}, as: :account))
|
|
# Provider (card 2)
|
|
|> assign(:providers, Provider.all())
|
|
|> assign(:selected_provider, nil)
|
|
|> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider))
|
|
|> assign(:provider_connecting, false)
|
|
|> assign(:provider_conn, provider_conn)
|
|
# Stripe (card 3)
|
|
|> assign(:stripe_form, to_form(%{"api_key" => ""}, as: :stripe))
|
|
|> assign(:stripe_connecting, false)
|
|
end
|
|
|
|
# ── Events: Secret gate ──
|
|
|
|
@impl true
|
|
def handle_event("verify_secret", %{"secret" => %{"secret" => secret}}, socket) do
|
|
if Plug.Crypto.secure_compare(secret, Setup.setup_secret()) do
|
|
{:noreply, assign(socket, secret_verified: true)}
|
|
else
|
|
{:noreply, put_flash(socket, :error, "Wrong setup secret")}
|
|
end
|
|
end
|
|
|
|
# ── Events: Account ──
|
|
|
|
def handle_event("create_account", %{"account" => params}, socket) do
|
|
email = params["email"]
|
|
shop_name = String.trim(params["shop_name"] || "")
|
|
|
|
if email == "" do
|
|
{:noreply, put_flash(socket, :error, "Please enter your email address")}
|
|
else
|
|
if shop_name != "" do
|
|
Settings.put_setting("site_name", shop_name, "string")
|
|
end
|
|
|
|
case Accounts.register_and_confirm_admin(%{email: email}) do
|
|
{:ok, user} ->
|
|
token = Accounts.generate_login_token(user)
|
|
{:noreply, redirect(socket, to: ~p"/setup/login/#{token}")}
|
|
|
|
{:error, :admin_already_exists} ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, "An admin account already exists")
|
|
|> push_navigate(to: ~p"/setup")}
|
|
|
|
{:error, changeset} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:account_form, to_form(changeset, as: :account))
|
|
|> put_flash(:error, "Could not create account")}
|
|
end
|
|
end
|
|
end
|
|
|
|
# ── Events: Provider ──
|
|
|
|
def handle_event("select_provider", %{"provider_select" => %{"type" => type}}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:selected_provider, type)
|
|
|> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider))}
|
|
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)
|
|
|
|
case Products.connect_provider(api_key, type) do
|
|
{:ok, connection} ->
|
|
setup = Setup.setup_status()
|
|
|
|
if setup.setup_complete do
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, "You're in! Here's your launch checklist.")
|
|
|> push_navigate(to: ~p"/admin")}
|
|
else
|
|
{:noreply,
|
|
socket
|
|
|> assign(:provider_connecting, false)
|
|
|> assign(:provider_conn, connection)
|
|
|> assign(:setup, setup)
|
|
|> put_flash(:info, "Connected! Product sync started in the background.")}
|
|
end
|
|
|
|
{:error, :no_api_key} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:provider_connecting, false)
|
|
|> put_flash(:error, "Please enter your API token")}
|
|
|
|
{:error, _reason} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:provider_connecting, false)
|
|
|> put_flash(:error, "Could not connect — check your API key and try again")}
|
|
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 = Setup.setup_status()
|
|
|
|
if setup.setup_complete do
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, "You're in! Here's your launch checklist.")
|
|
|> push_navigate(to: ~p"/admin")}
|
|
else
|
|
{:noreply,
|
|
socket
|
|
|> assign(:stripe_connecting, false)
|
|
|> assign(:setup, setup)
|
|
|> put_flash(:info, "Stripe connected")}
|
|
end
|
|
|
|
{: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>
|
|
<%= if @require_secret? and not @secret_verified do %>
|
|
<p class="setup-subtitle">
|
|
Enter the setup secret from your server logs to get started.
|
|
</p>
|
|
<% else %>
|
|
<p class="setup-subtitle">
|
|
Connect your accounts to get going.
|
|
</p>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%!-- Secret gate (prod only, before admin exists) --%>
|
|
<%= if @require_secret? and not @secret_verified do %>
|
|
<div class="setup-sections">
|
|
<div class="setup-card">
|
|
<div class="setup-card-body">
|
|
<p class="setup-hint">
|
|
Find the setup secret in your server logs or set the <code>SETUP_SECRET</code>
|
|
environment variable.
|
|
</p>
|
|
<.form for={@secret_form} phx-submit="verify_secret">
|
|
<.input
|
|
field={@secret_form[:secret]}
|
|
type="password"
|
|
label="Setup secret"
|
|
autocomplete="off"
|
|
required
|
|
phx-mounted={JS.focus()}
|
|
/>
|
|
<div class="setup-actions">
|
|
<.button phx-disable-with="Checking...">Continue</.button>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<%!-- All three setup cards --%>
|
|
<div class="setup-sections">
|
|
<.section_card
|
|
title="Set up your account"
|
|
number={1}
|
|
done={@logged_in?}
|
|
summary={account_summary(assigns)}
|
|
>
|
|
<p class="setup-hint">Name your shop and create the admin account.</p>
|
|
<.form for={@account_form} phx-submit="create_account">
|
|
<.input
|
|
field={@account_form[:shop_name]}
|
|
type="text"
|
|
label="Shop name"
|
|
placeholder="e.g. Acme Prints"
|
|
autocomplete="off"
|
|
phx-mounted={@current_step == 1 && JS.focus()}
|
|
/>
|
|
<.input
|
|
field={@account_form[:email]}
|
|
type="email"
|
|
label="Email address"
|
|
autocomplete="email"
|
|
required
|
|
/>
|
|
<div class="setup-actions">
|
|
<.button phx-disable-with="Creating account...">Create account</.button>
|
|
</div>
|
|
</.form>
|
|
</.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}
|
|
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}
|
|
focus={@current_step == 3}
|
|
/>
|
|
</.section_card>
|
|
</div>
|
|
|
|
<%!-- All done --%>
|
|
<div :if={@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">→</span>
|
|
</.link>
|
|
</div>
|
|
<% end %>
|
|
</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 :connecting, :boolean, required: true
|
|
|
|
defp provider_section(assigns) do
|
|
assigns = assign(assigns, :provider_options, provider_card_options(assigns.providers))
|
|
|
|
~H"""
|
|
<div>
|
|
<p class="setup-hint">Choose a print-on-demand provider and connect your API key.</p>
|
|
|
|
<.form for={%{}} as={:provider_select} phx-change="select_provider">
|
|
<.card_radio_group
|
|
name="provider_select[type]"
|
|
value={@selected}
|
|
legend="Print provider"
|
|
options={@provider_options}
|
|
/>
|
|
</.form>
|
|
|
|
<%!-- 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} →
|
|
</a>
|
|
</p>
|
|
|
|
<.form for={@form} 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="submit" disabled={@connecting}>
|
|
{if @connecting, do: "Connecting...", else: "Connect"}
|
|
</.button>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# ── Stripe section ──
|
|
|
|
attr :form, :any, required: true
|
|
attr :connecting, :boolean, required: true
|
|
attr :focus, :boolean, default: false
|
|
|
|
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 →
|
|
</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_..."
|
|
phx-mounted={@focus && JS.focus()}
|
|
/>
|
|
<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"}
|
|
</.button>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# ── Helpers ──
|
|
|
|
defp account_summary(%{current_scope: %{user: user}}) when not is_nil(user) do
|
|
site_name = Settings.site_name()
|
|
|
|
if site_name != "Store Name" do
|
|
"#{site_name} · #{user.email}"
|
|
else
|
|
user.email
|
|
end
|
|
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 provider_card_options(providers) do
|
|
Enum.map(providers, fn provider ->
|
|
option = %{
|
|
value: provider.type,
|
|
name: provider.name,
|
|
description: provider.tagline
|
|
}
|
|
|
|
if provider.status == :coming_soon do
|
|
Map.merge(option, %{badge: "Coming soon", disabled: true})
|
|
else
|
|
option
|
|
end
|
|
end)
|
|
end
|
|
end
|