berrypod/lib/berrypod_web/live/setup/onboarding.ex
jamey 156a23da16 add external link UX: icons, rel attributes, screen reader labels
New external_link component in core_components handles target="_blank",
rel="noopener noreferrer", external-link icon, and sr-only "(opens in
new tab)" text. Migrated admin providers form, settings (Stripe),
order tracking, onboarding setup links to use it. Fixed rel="noopener"
to "noopener noreferrer" on remaining links (email settings, product
show, core_components card radio). Added sr-only text to shop social
link cards and aria-label to page renderer tracking link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:55:09 +00:00

506 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" => "", "password" => "", "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"]
password = params["password"]
shop_name = String.trim(params["shop_name"] || "")
cond do
shop_name == "" ->
{:noreply, put_flash(socket, :error, "Please enter a shop name")}
email == "" ->
{:noreply, put_flash(socket, :error, "Please enter your email address")}
true ->
Settings.put_setting("site_name", shop_name, "string")
case Accounts.register_and_confirm_admin(%{email: email, password: password}) 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} ->
form = to_form(params, as: :account, errors: changeset.errors, action: :validate)
{:noreply,
socket
|> assign(:account_form, form)
|> 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"
required
phx-mounted={@current_step == 1 && JS.focus()}
/>
<.input
field={@account_form[:email]}
type="email"
label="Email address"
autocomplete="email"
required
/>
<.input
field={@account_form[:password]}
type="password"
label="Password"
placeholder="12 characters minimum"
autocomplete="new-password"
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">&rarr;</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}.
<.external_link href={provider_info.setup_url} class="setup-link">
Open {provider_info.name}
</.external_link>
</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.
<.external_link href="https://dashboard.stripe.com/apikeys" class="setup-link">
Open Stripe dashboard
</.external_link>
</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