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"""

Set up your shop

<%= if @require_secret? and not @secret_verified do %>

Enter the setup secret from your server logs to get started.

<% else %>

Connect your accounts to get going.

<% end %>
<%!-- Secret gate (prod only, before admin exists) --%> <%= if @require_secret? and not @secret_verified do %>

Find the setup secret in your server logs or set the SETUP_SECRET environment variable.

<.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()} />
<.button phx-disable-with="Checking...">Continue
<% else %> <%!-- Account creation (before login) --%>
<.section_card title="Set up your account" number={1} done={false} summary={nil} >

Name your shop and create the admin account.

<.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()} />

You can change this later

<.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 />
<.button phx-disable-with="Creating account...">Create account
<%!-- Guided setup flow (after login) --%>
<%!-- Progress bar --%> <.progress_bar current_step={@guided_step} setup={@setup} /> <%!-- Intro screen --%>

Berrypod connects your print-on-demand products to your own online shop. To get fully set up, you'll need three things:

  • <.icon name="hero-cube" class="setup-intro-icon" />
    A print provider account (like Printify or Printful) to make and ship your products
  • <.icon name="hero-credit-card" class="setup-intro-icon" />
    A Stripe account to accept payments from customers
  • <.icon name="hero-envelope" class="setup-intro-icon" />
    An email provider account so your shop can send order confirmations and shipping updates

Don't worry if you don't have all of these yet — you can skip any step and set it up later.

<.button phx-click="start_setup"> Let's get started
<%!-- Step 1: Provider --%>
<.step_header number={1} title="Connect a print provider" done={@setup.provider_connected} />
<.provider_section providers={@providers} selected={@selected_provider} form={@provider_form} connecting={@provider_connecting} />
<.step_nav step={:provider} can_skip={true} skip_note="You won't be able to import products until you connect a provider." />
<%!-- Step 2: Stripe --%>
<.step_header number={2} title="Connect Stripe for payments" done={@setup.stripe_connected} />
<.stripe_section form={@stripe_form} connecting={@stripe_connecting} focus={true} />
<.step_nav step={:stripe} can_skip={true} skip_note="Customers won't be able to checkout until you connect Stripe." />
<%!-- Step 3: Email --%>
<.step_header number={3} title="Set up email" done={@setup.email_configured} />

Configure an email provider so your shop can send order confirmations, shipping updates, and newsletters.

<%= if @setup.email_configured do %>
<.icon name="hero-check-circle" class="setup-step-done-icon" />

Email is already configured.

<.button phx-click="skip_step"> Continue
<% else %>

<.link navigate={~p"/admin/settings/email"} class="setup-link"> Set up email in settings

<% end %>
<.step_nav step={:email} can_skip={true} skip_note="Order confirmations and shipping updates won't be sent." finish_button={true} />
<% end %>
""" 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"""
<%= if @done do %> <.icon name="hero-check-mini" class="size-4" /> <% else %> {@number} <% end %>

{@title}

<%= if @done and @summary do %>

{@summary}

<% else %>
{render_slot(@inner_block)}
<% end %>
""" 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""" """ 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"""
<%= if @done do %> <.icon name="hero-check-mini" class="size-5" /> <% else %> {@number} <% end %>

{@title}

""" 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"""
<%= if @step != :provider do %> <% end %>
<%= if @can_skip do %>
<%= if @skip_note do %>

{@skip_note}

<% end %>
<% end %>
""" 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"""

Choose a print-on-demand provider and connect your API key.

<.form for={%{}} as={:provider_select} phx-change="select_provider"> <.card_radio_group name="provider_select[type]" value={@selected} legend="Print provider" options={@provider_options} /> <%!-- API key form for selected provider --%>
<% provider_info = Enum.find(@providers, &(&1.type == @selected)) %>

{provider_info.setup_hint}. <.external_link href={provider_info.setup_url} class="setup-link"> Open {provider_info.name}

<.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" />
<.button type="submit" disabled={@connecting}> {if @connecting, do: "Connecting...", else: "Connect"}
""" end # ── Stripe section ── attr :form, :any, required: true attr :connecting, :boolean, required: true attr :focus, :boolean, default: false defp stripe_section(assigns) do ~H"""

Enter your Stripe secret key to accept payments. <.external_link href="https://dashboard.stripe.com/apikeys" class="setup-link"> Open Stripe dashboard

<.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" />
<.button phx-disable-with="Connecting..."> {if @connecting, do: "Connecting...", else: "Connect"}
""" end # ── Helpers ── 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