Some checks failed
deploy / deploy (push) Has been cancelled
Tasks C, H, I from the plan: - Forgiving API key validation: add Printify UUID format and Printful length validation, validate on blur for fast feedback, helpful error messages with specific guidance - External links UX: verified all external links use <.external_link> component with target="_blank", rel="noopener noreferrer", icon, and screen reader text - Input styling WCAG compliance: increase input border contrast from ~3.3:1 to ~4.5-5:1 across all theme moods (neutral, warm, cool, dark) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
945 lines
28 KiB
Elixir
945 lines
28 KiB
Elixir
defmodule BerrypodWeb.Setup.Onboarding do
|
|
use BerrypodWeb, :live_view
|
|
|
|
alias Berrypod.{Accounts, KeyValidation, Products, Settings, Setup}
|
|
alias Berrypod.Providers.Provider
|
|
alias Berrypod.Stripe.Setup, as: StripeSetup
|
|
|
|
# Steps in the guided flow (after account creation):
|
|
# :intro - explains what's needed
|
|
# :provider - connect print provider
|
|
# :stripe - connect Stripe payments
|
|
# :email - set up email provider
|
|
|
|
@guided_steps [:intro, :provider, :stripe, :email]
|
|
|
|
# ── 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()
|
|
|
|
# Determine which guided step to show (for logged-in users)
|
|
guided_step = determine_guided_step(setup)
|
|
|
|
socket
|
|
|> assign(:page_title, "Set up your shop")
|
|
|> assign(:setup, setup)
|
|
|> assign(:logged_in?, logged_in?)
|
|
|> assign(:guided_step, guided_step)
|
|
|> assign(:guided_steps, @guided_steps)
|
|
# 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" => "", "password_confirmation" => "", "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
|
|
|
|
# Determine which guided step to show based on what's already configured
|
|
defp determine_guided_step(setup) do
|
|
cond do
|
|
# Show intro on first visit after account creation
|
|
not setup.provider_connected and not setup.stripe_connected and
|
|
not setup.email_configured ->
|
|
:intro
|
|
|
|
# Resume at first incomplete step
|
|
not setup.provider_connected ->
|
|
:provider
|
|
|
|
not setup.stripe_connected ->
|
|
:stripe
|
|
|
|
not setup.email_configured ->
|
|
:email
|
|
|
|
# All done - will redirect to dashboard
|
|
true ->
|
|
:email
|
|
end
|
|
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: Guided flow navigation ──
|
|
|
|
def handle_event("start_setup", _params, socket) do
|
|
{:noreply, assign(socket, :guided_step, :provider)}
|
|
end
|
|
|
|
def handle_event("go_to_step", %{"step" => step}, socket) do
|
|
step = String.to_existing_atom(step)
|
|
|
|
if step in @guided_steps do
|
|
{:noreply, assign(socket, :guided_step, step)}
|
|
else
|
|
{:noreply, socket}
|
|
end
|
|
end
|
|
|
|
def handle_event("skip_step", _params, socket) do
|
|
next_step = next_guided_step(socket.assigns.guided_step)
|
|
handle_step_completion(socket, next_step)
|
|
end
|
|
|
|
def handle_event("go_back", _params, socket) do
|
|
prev_step = prev_guided_step(socket.assigns.guided_step)
|
|
{:noreply, assign(socket, :guided_step, prev_step)}
|
|
end
|
|
|
|
# ── Events: Account ──
|
|
|
|
def handle_event("validate_account", %{"account" => params}, socket) do
|
|
errors = validate_account_fields(params)
|
|
|
|
form =
|
|
to_form(params,
|
|
as: :account,
|
|
errors: errors,
|
|
action: if(errors != [], do: :validate)
|
|
)
|
|
|
|
{:noreply, assign(socket, :account_form, form)}
|
|
end
|
|
|
|
def handle_event("create_account", %{"account" => params}, socket) do
|
|
email = params["email"]
|
|
password = params["password"]
|
|
password_confirmation = params["password_confirmation"]
|
|
shop_name = String.trim(params["shop_name"] || "")
|
|
|
|
errors = validate_account_fields(params)
|
|
|
|
cond do
|
|
errors != [] ->
|
|
form = to_form(params, as: :account, errors: errors, action: :validate)
|
|
{:noreply, assign(socket, :account_form, form)}
|
|
|
|
password != password_confirmation ->
|
|
form =
|
|
to_form(params,
|
|
as: :account,
|
|
errors: [password_confirmation: {"Passwords don't match", []}],
|
|
action: :validate
|
|
)
|
|
|
|
{:noreply, assign(socket, :account_form, form)}
|
|
|
|
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
|
|
|
|
case KeyValidation.validate_provider_key(api_key, type) do
|
|
{:error, message} ->
|
|
form =
|
|
to_form(%{"api_key" => api_key},
|
|
as: :provider,
|
|
errors: [api_key: {message, []}],
|
|
action: :validate
|
|
)
|
|
|
|
{:noreply, assign(socket, :provider_form, form)}
|
|
|
|
{:ok, api_key} ->
|
|
socket = assign(socket, :provider_connecting, true)
|
|
|
|
case Products.connect_provider(api_key, type) do
|
|
{:ok, connection} ->
|
|
setup = Setup.setup_status()
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:provider_connecting, false)
|
|
|> assign(:provider_conn, connection)
|
|
|> assign(:setup, setup)
|
|
|> assign(:guided_step, :stripe)
|
|
|> put_flash(:info, "Connected! Product sync started in the background.")}
|
|
|
|
{:error, :no_api_key} ->
|
|
form =
|
|
to_form(%{"api_key" => api_key},
|
|
as: :provider,
|
|
errors: [api_key: {"Please enter your API token", []}],
|
|
action: :validate
|
|
)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:provider_connecting, false)
|
|
|> assign(:provider_form, form)}
|
|
|
|
{:error, _reason} ->
|
|
form =
|
|
to_form(%{"api_key" => api_key},
|
|
as: :provider,
|
|
errors: [api_key: {"Could not connect. Check your API key and try again", []}],
|
|
action: :validate
|
|
)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:provider_connecting, false)
|
|
|> assign(:provider_form, form)}
|
|
end
|
|
end
|
|
end
|
|
|
|
# Validate provider key on blur for fast feedback
|
|
def handle_event("validate_provider", %{"provider" => %{"api_key" => api_key}}, socket) do
|
|
type = socket.assigns.selected_provider
|
|
|
|
form =
|
|
case KeyValidation.validate_provider_key(api_key, type) do
|
|
{:ok, _} ->
|
|
to_form(%{"api_key" => api_key}, as: :provider)
|
|
|
|
{:error, message} ->
|
|
to_form(%{"api_key" => api_key},
|
|
as: :provider,
|
|
errors: [api_key: {message, []}],
|
|
action: :validate
|
|
)
|
|
end
|
|
|
|
{:noreply, assign(socket, :provider_form, form)}
|
|
end
|
|
|
|
# ── Events: Stripe ──
|
|
|
|
# Validate Stripe key on blur for fast feedback
|
|
def handle_event("validate_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
|
|
form =
|
|
case KeyValidation.validate_stripe_key(api_key) do
|
|
{:ok, _} ->
|
|
to_form(%{"api_key" => api_key}, as: :stripe)
|
|
|
|
{:error, message} ->
|
|
to_form(%{"api_key" => api_key},
|
|
as: :stripe,
|
|
errors: [api_key: {message, []}],
|
|
action: :validate
|
|
)
|
|
end
|
|
|
|
{:noreply, assign(socket, :stripe_form, form)}
|
|
end
|
|
|
|
def handle_event("connect_stripe", %{"stripe" => %{"api_key" => api_key}}, socket) do
|
|
case KeyValidation.validate_stripe_key(api_key) do
|
|
{:error, message} ->
|
|
form =
|
|
to_form(%{"api_key" => api_key},
|
|
as: :stripe,
|
|
errors: [api_key: {message, []}],
|
|
action: :validate
|
|
)
|
|
|
|
{:noreply, assign(socket, :stripe_form, form)}
|
|
|
|
{:ok, api_key} ->
|
|
socket = assign(socket, :stripe_connecting, true)
|
|
|
|
case StripeSetup.connect(api_key) do
|
|
{:ok, _result} ->
|
|
setup = Setup.setup_status()
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:stripe_connecting, false)
|
|
|> assign(:setup, setup)
|
|
|> assign(:guided_step, :email)
|
|
|> put_flash(:info, "Stripe connected")}
|
|
|
|
{:error, message} ->
|
|
form =
|
|
to_form(%{"api_key" => api_key},
|
|
as: :stripe,
|
|
errors: [api_key: {"Stripe connection failed: #{message}", []}],
|
|
action: :validate
|
|
)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:stripe_connecting, false)
|
|
|> assign(:stripe_form, form)}
|
|
end
|
|
end
|
|
end
|
|
|
|
# ── Navigation helpers ──
|
|
|
|
defp next_guided_step(current) do
|
|
case current do
|
|
:intro -> :provider
|
|
:provider -> :stripe
|
|
:stripe -> :email
|
|
:email -> :done
|
|
end
|
|
end
|
|
|
|
defp prev_guided_step(current) do
|
|
case current do
|
|
:provider -> :intro
|
|
:stripe -> :provider
|
|
:email -> :stripe
|
|
_ -> current
|
|
end
|
|
end
|
|
|
|
defp handle_step_completion(socket, :done) do
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, "Setup complete! Here's your launch checklist.")
|
|
|> push_navigate(to: ~p"/admin")}
|
|
end
|
|
|
|
defp handle_step_completion(socket, next_step) do
|
|
{:noreply, assign(socket, :guided_step, next_step)}
|
|
end
|
|
|
|
# ── Render ──
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<Layouts.flash_group flash={@flash} />
|
|
<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 %>
|
|
<%!-- Account creation (before login) --%>
|
|
<div :if={not @logged_in?} class="setup-sections">
|
|
<.section_card
|
|
title="Set up your account"
|
|
number={1}
|
|
done={false}
|
|
summary={nil}
|
|
>
|
|
<p class="setup-hint">Name your shop and create the admin account.</p>
|
|
<.form for={@account_form} phx-submit="create_account" phx-change="validate_account">
|
|
<.input
|
|
field={@account_form[:shop_name]}
|
|
type="text"
|
|
label="Shop name"
|
|
placeholder="e.g. Acme Prints"
|
|
autocomplete="off"
|
|
required
|
|
phx-mounted={JS.focus()}
|
|
/>
|
|
<p class="setup-field-note">You can change this later</p>
|
|
<.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
|
|
/>
|
|
<.input
|
|
field={@account_form[:password_confirmation]}
|
|
type="password"
|
|
label="Confirm password"
|
|
autocomplete="new-password"
|
|
required
|
|
/>
|
|
<div class="setup-actions">
|
|
<.button phx-disable-with="Creating account...">Create account</.button>
|
|
</div>
|
|
</.form>
|
|
</.section_card>
|
|
</div>
|
|
|
|
<%!-- Guided setup flow (after login) --%>
|
|
<div :if={@logged_in?} class="setup-guided">
|
|
<%!-- Progress bar --%>
|
|
<.progress_bar
|
|
current_step={@guided_step}
|
|
setup={@setup}
|
|
/>
|
|
|
|
<%!-- Intro screen --%>
|
|
<div :if={@guided_step == :intro} class="setup-intro">
|
|
<div class="setup-intro-content">
|
|
<p class="setup-intro-lead">
|
|
Berrypod connects your print-on-demand products to your own online shop.
|
|
To get fully set up, you'll need three things:
|
|
</p>
|
|
<ul class="setup-intro-list">
|
|
<li>
|
|
<.icon name="hero-cube" class="setup-intro-icon" />
|
|
<div>
|
|
<strong>A print provider account</strong>
|
|
<span>(like Printify or Printful) to make and ship your products</span>
|
|
</div>
|
|
</li>
|
|
<li>
|
|
<.icon name="hero-credit-card" class="setup-intro-icon" />
|
|
<div>
|
|
<strong>A Stripe account</strong>
|
|
<span>to accept payments from customers</span>
|
|
</div>
|
|
</li>
|
|
<li>
|
|
<.icon name="hero-envelope" class="setup-intro-icon" />
|
|
<div>
|
|
<strong>An email provider account</strong>
|
|
<span>so your shop can send order confirmations and shipping updates</span>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
<p class="setup-intro-note">
|
|
Don't worry if you don't have all of these yet — you can skip any step and set it up later.
|
|
</p>
|
|
</div>
|
|
<div class="setup-actions setup-actions-center">
|
|
<.button phx-click="start_setup">
|
|
Let's get started <span aria-hidden="true">→</span>
|
|
</.button>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Step 1: Provider --%>
|
|
<div :if={@guided_step == :provider} class="setup-step">
|
|
<.step_header
|
|
number={1}
|
|
title="Connect a print provider"
|
|
done={@setup.provider_connected}
|
|
/>
|
|
<div class="setup-step-body">
|
|
<.provider_section
|
|
providers={@providers}
|
|
selected={@selected_provider}
|
|
form={@provider_form}
|
|
connecting={@provider_connecting}
|
|
/>
|
|
</div>
|
|
<.step_nav
|
|
step={:provider}
|
|
can_skip={true}
|
|
skip_note="You won't be able to import products until you connect a provider."
|
|
/>
|
|
</div>
|
|
|
|
<%!-- Step 2: Stripe --%>
|
|
<div :if={@guided_step == :stripe} class="setup-step">
|
|
<.step_header
|
|
number={2}
|
|
title="Connect Stripe for payments"
|
|
done={@setup.stripe_connected}
|
|
/>
|
|
<div class="setup-step-body">
|
|
<.stripe_section
|
|
form={@stripe_form}
|
|
connecting={@stripe_connecting}
|
|
focus={true}
|
|
/>
|
|
</div>
|
|
<.step_nav
|
|
step={:stripe}
|
|
can_skip={true}
|
|
skip_note="Customers won't be able to checkout until you connect Stripe."
|
|
/>
|
|
</div>
|
|
|
|
<%!-- Step 3: Email --%>
|
|
<div :if={@guided_step == :email} class="setup-step">
|
|
<.step_header
|
|
number={3}
|
|
title="Set up email"
|
|
done={@setup.email_configured}
|
|
/>
|
|
<div class="setup-step-body">
|
|
<p class="setup-hint">
|
|
Configure an email provider so your shop can send order confirmations,
|
|
shipping updates, and newsletters.
|
|
</p>
|
|
<%= if @setup.email_configured do %>
|
|
<div class="setup-step-done">
|
|
<.icon name="hero-check-circle" class="setup-step-done-icon" />
|
|
<p>Email is already configured.</p>
|
|
</div>
|
|
<div class="setup-actions">
|
|
<.button phx-click="skip_step">
|
|
Continue <span aria-hidden="true">→</span>
|
|
</.button>
|
|
</div>
|
|
<% else %>
|
|
<p class="setup-hint">
|
|
<.link navigate={~p"/admin/settings/email"} class="setup-link">
|
|
Set up email in settings <span aria-hidden="true">→</span>
|
|
</.link>
|
|
</p>
|
|
<% end %>
|
|
</div>
|
|
<.step_nav
|
|
step={:email}
|
|
can_skip={true}
|
|
skip_note="Order confirmations and shipping updates won't be sent."
|
|
finish_button={true}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# ── Section card component ──
|
|
|
|
attr :title, :string, required: true
|
|
attr :number, :integer, required: true
|
|
attr :done, :boolean, required: true
|
|
attr :summary, :any, 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
|
|
|
|
# ── Progress bar component ──
|
|
|
|
attr :current_step, :atom, required: true
|
|
attr :setup, :map, required: true
|
|
|
|
defp progress_bar(assigns) do
|
|
steps = [
|
|
%{key: :provider, label: "Provider", done: assigns.setup.provider_connected},
|
|
%{key: :stripe, label: "Payments", done: assigns.setup.stripe_connected},
|
|
%{key: :email, label: "Email", done: assigns.setup.email_configured}
|
|
]
|
|
|
|
current_index =
|
|
case assigns.current_step do
|
|
:intro -> -1
|
|
:provider -> 0
|
|
:stripe -> 1
|
|
:email -> 2
|
|
_ -> -1
|
|
end
|
|
|
|
assigns = assign(assigns, steps: steps, current_index: current_index)
|
|
|
|
~H"""
|
|
<nav class="setup-progress" aria-label="Setup progress">
|
|
<ol class="setup-progress-list">
|
|
<%= for {step, index} <- Enum.with_index(@steps) do %>
|
|
<li class={[
|
|
"setup-progress-item",
|
|
step.done && "setup-progress-done",
|
|
index == @current_index && "setup-progress-current"
|
|
]}>
|
|
<button
|
|
type="button"
|
|
phx-click="go_to_step"
|
|
phx-value-step={step.key}
|
|
class="setup-progress-button"
|
|
aria-current={index == @current_index && "step"}
|
|
disabled={@current_step == :intro}
|
|
>
|
|
<span class="setup-progress-indicator">
|
|
<%= if step.done do %>
|
|
<.icon name="hero-check-mini" class="size-4" />
|
|
<% else %>
|
|
{index + 1}
|
|
<% end %>
|
|
</span>
|
|
<span class="setup-progress-label">{step.label}</span>
|
|
</button>
|
|
</li>
|
|
<% end %>
|
|
</ol>
|
|
</nav>
|
|
"""
|
|
end
|
|
|
|
# ── Step header component ──
|
|
|
|
attr :number, :integer, required: true
|
|
attr :title, :string, required: true
|
|
attr :done, :boolean, default: false
|
|
|
|
defp step_header(assigns) do
|
|
~H"""
|
|
<div class="setup-step-header">
|
|
<span class={["setup-step-number", @done && "setup-step-number-done"]}>
|
|
<%= if @done do %>
|
|
<.icon name="hero-check-mini" class="size-5" />
|
|
<% else %>
|
|
{@number}
|
|
<% end %>
|
|
</span>
|
|
<h2 class="setup-step-title">{@title}</h2>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# ── Step navigation component ──
|
|
|
|
attr :step, :atom, required: true
|
|
attr :can_skip, :boolean, default: false
|
|
attr :skip_note, :string, default: nil
|
|
attr :finish_button, :boolean, default: false
|
|
|
|
defp step_nav(assigns) do
|
|
~H"""
|
|
<div class="setup-step-nav">
|
|
<div class="setup-step-nav-left">
|
|
<%= if @step != :provider do %>
|
|
<button type="button" phx-click="go_back" class="setup-step-back">
|
|
<.icon name="hero-arrow-left-mini" class="size-4" /> Back
|
|
</button>
|
|
<% end %>
|
|
</div>
|
|
<div class="setup-step-nav-right">
|
|
<%= if @can_skip do %>
|
|
<div class="setup-step-skip">
|
|
<button type="button" phx-click="skip_step" class="setup-step-skip-btn">
|
|
<%= if @finish_button do %>
|
|
Finish setup
|
|
<% else %>
|
|
Skip for now
|
|
<% end %>
|
|
</button>
|
|
<%= if @skip_note do %>
|
|
<p class="setup-step-skip-note">{@skip_note}</p>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</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" phx-change="validate_provider">
|
|
<.input
|
|
field={@form[:api_key]}
|
|
type="text"
|
|
label="API token"
|
|
placeholder="Paste your token here"
|
|
autocomplete="off"
|
|
phx-debounce="blur"
|
|
/>
|
|
|
|
<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" phx-change="validate_stripe">
|
|
<.input
|
|
field={@form[:api_key]}
|
|
type="text"
|
|
label="Secret key"
|
|
autocomplete="off"
|
|
placeholder="sk_test_... or sk_live_..."
|
|
phx-mounted={@focus && JS.focus()}
|
|
phx-debounce="blur"
|
|
/>
|
|
<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
|
|
|
|
# Live validation for account creation form
|
|
defp validate_account_fields(params) do
|
|
errors = []
|
|
|
|
shop_name = String.trim(params["shop_name"] || "")
|
|
email = String.trim(params["email"] || "")
|
|
password = params["password"] || ""
|
|
password_confirmation = params["password_confirmation"] || ""
|
|
|
|
errors =
|
|
if shop_name == "" and params["shop_name"] != "" do
|
|
[{:shop_name, {"Shop name can't be blank", []}} | errors]
|
|
else
|
|
errors
|
|
end
|
|
|
|
errors =
|
|
if email != "" and not valid_email?(email) do
|
|
[{:email, {"Please enter a valid email address", []}} | errors]
|
|
else
|
|
errors
|
|
end
|
|
|
|
errors =
|
|
if password != "" and String.length(password) < 12 do
|
|
[{:password, {"Password must be at least 12 characters", []}} | errors]
|
|
else
|
|
errors
|
|
end
|
|
|
|
errors =
|
|
if password_confirmation != "" and password != password_confirmation do
|
|
[{:password_confirmation, {"Passwords don't match", []}} | errors]
|
|
else
|
|
errors
|
|
end
|
|
|
|
Enum.reverse(errors)
|
|
end
|
|
|
|
defp valid_email?(email) do
|
|
# Basic email format check
|
|
String.match?(email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
|
|
end
|
|
end
|