complete onboarding UX v2
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>
This commit is contained in:
jamey
2026-03-10 07:56:54 +00:00
parent 951147a675
commit 2282af91db
15 changed files with 1034 additions and 153 deletions

View File

@@ -5,6 +5,14 @@ defmodule BerrypodWeb.Setup.Onboarding do
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
@@ -37,18 +45,15 @@ defmodule BerrypodWeb.Setup.Onboarding 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
# 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(:current_step, current_step)
|> assign(:guided_step, guided_step)
|> assign(:guided_steps, @guided_steps)
# Secret gate
|> assign(:require_secret?, Setup.require_setup_secret?())
|> assign(:secret_verified, false)
@@ -56,7 +61,10 @@ defmodule BerrypodWeb.Setup.Onboarding do
# Account (card 1)
|> assign(
:account_form,
to_form(%{"email" => "", "password" => "", "shop_name" => ""}, as: :account)
to_form(
%{"email" => "", "password" => "", "password_confirmation" => "", "shop_name" => ""},
as: :account
)
)
# Provider (card 2)
|> assign(:providers, Provider.all())
@@ -69,6 +77,30 @@ defmodule BerrypodWeb.Setup.Onboarding do
|> 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
@@ -80,19 +112,69 @@ defmodule BerrypodWeb.Setup.Onboarding do
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"] || "")
cond do
shop_name == "" ->
{:noreply, put_flash(socket, :error, "Please enter a shop name")}
errors = validate_account_fields(params)
email == "" ->
{:noreply, put_flash(socket, :error, "Please enter your email address")}
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")
@@ -149,19 +231,13 @@ defmodule BerrypodWeb.Setup.Onboarding 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
{: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 =
@@ -192,8 +268,46 @@ defmodule BerrypodWeb.Setup.Onboarding do
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} ->
@@ -213,18 +327,12 @@ defmodule BerrypodWeb.Setup.Onboarding 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
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> assign(:setup, setup)
|> assign(:guided_step, :email)
|> put_flash(:info, "Stripe connected")}
{:error, message} ->
form =
@@ -242,6 +350,37 @@ defmodule BerrypodWeb.Setup.Onboarding do
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
@@ -288,16 +427,16 @@ defmodule BerrypodWeb.Setup.Onboarding do
</div>
</div>
<% else %>
<%!-- All three setup cards --%>
<div class="setup-sections">
<%!-- Account creation (before login) --%>
<div :if={not @logged_in?} class="setup-sections">
<.section_card
title="Set up your account"
number={1}
done={@logged_in?}
summary={account_summary(assigns)}
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">
<.form for={@account_form} phx-submit="create_account" phx-change="validate_account">
<.input
field={@account_form[:shop_name]}
type="text"
@@ -305,8 +444,9 @@ defmodule BerrypodWeb.Setup.Onboarding do
placeholder="e.g. Acme Prints"
autocomplete="off"
required
phx-mounted={@current_step == 1 && JS.focus()}
phx-mounted={JS.focus()}
/>
<p class="setup-field-note">You can change this later</p>
<.input
field={@account_form[:email]}
type="email"
@@ -322,48 +462,149 @@ defmodule BerrypodWeb.Setup.Onboarding do
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>
<.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>
<%!-- 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">&rarr;</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">&rarr;</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">&rarr;</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>
@@ -375,7 +616,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
attr :title, :string, required: true
attr :number, :integer, required: true
attr :done, :boolean, required: true
attr :summary, :string, default: nil
attr :summary, :any, default: nil
slot :inner_block, required: true
@@ -404,6 +645,120 @@ defmodule BerrypodWeb.Setup.Onboarding do
"""
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
@@ -437,13 +792,14 @@ defmodule BerrypodWeb.Setup.Onboarding do
</.external_link>
</p>
<.form for={@form} phx-submit="connect_provider">
<.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">
@@ -473,7 +829,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
</.external_link>
</p>
<.form for={@form} phx-submit="connect_stripe">
<.form for={@form} phx-submit="connect_stripe" phx-change="validate_stripe">
<.input
field={@form[:api_key]}
type="text"
@@ -481,6 +837,7 @@ defmodule BerrypodWeb.Setup.Onboarding do
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...">
@@ -539,4 +896,49 @@ defmodule BerrypodWeb.Setup.Onboarding do
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