extract setup wizard to dedicated /admin/setup page

Move the setup stepper out of the dashboard into its own LiveView.
Dashboard now redirects to setup when site isn't live, and shows
stats-only view once live. Also cleans up button component variant
handling, fixes alert CSS, and removes stale demo.html.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-18 23:55:42 +00:00
parent 34aa8190d6
commit 559798206f
23 changed files with 835 additions and 7013 deletions

View File

@ -375,7 +375,7 @@
position: fixed; position: fixed;
top: 1rem; top: 1rem;
right: 1rem; right: 1rem;
z-index: 50; z-index: 100;
} }
.admin-alert { .admin-alert {
@ -389,15 +389,15 @@
} }
.admin-alert-info { .admin-alert-info {
background-color: color-mix(in oklch, var(--color-info) 10%, transparent); background-color: color-mix(in oklch, var(--color-info) 12%, var(--color-base-100));
color: var(--color-info); color: var(--color-info);
border: 1px solid color-mix(in oklch, var(--color-info) 25%, transparent); border: 1px solid color-mix(in oklch, var(--color-info) 25%, var(--color-base-100));
} }
.admin-alert-error { .admin-alert-error {
background-color: color-mix(in oklch, var(--color-error) 10%, transparent); background-color: color-mix(in oklch, var(--color-error) 12%, var(--color-base-100));
color: var(--color-error); color: var(--color-error);
border: 1px solid color-mix(in oklch, var(--color-error) 25%, transparent); border: 1px solid color-mix(in oklch, var(--color-error) 25%, var(--color-base-100));
} }
/* ── Modal ── */ /* ── Modal ── */

View File

@ -90,6 +90,13 @@ defmodule Berrypod.Products do
|> Repo.update_all(set: [sync_status: "idle"]) |> Repo.update_all(set: [sync_status: "idle"])
end end
@doc """
Returns the total count of all products.
"""
def count_products do
Repo.aggregate(Product, :count)
end
@doc """ @doc """
Returns the count of products for a provider connection. Returns the count of products for a provider connection.
""" """

View File

@ -8,10 +8,14 @@ defmodule BerrypodWeb.AdminLayoutHook do
socket = socket =
socket socket
|> assign(:current_path, "") |> assign(:current_path, "")
|> assign(:site_live, Berrypod.Settings.site_live?())
|> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params, |> Phoenix.LiveView.attach_hook(:set_current_path, :handle_params, fn _params,
uri, uri,
socket -> socket ->
{:cont, assign(socket, :current_path, URI.parse(uri).path)} {:cont,
socket
|> assign(:current_path, URI.parse(uri).path)
|> assign(:site_live, Berrypod.Settings.site_live?())}
end) end)
{:cont, socket} {:cont, socket}

View File

@ -81,16 +81,24 @@ defmodule BerrypodWeb.CoreComponents do
""" """
attr :rest, :global, include: ~w(href navigate patch method download name value disabled) attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :string attr :class, :string
attr :variant, :string, values: ~w(primary) attr :variant, :string, values: ~w(primary outline)
slot :inner_block, required: true slot :inner_block, required: true
def button(%{rest: rest} = assigns) do def button(%{rest: rest} = assigns) do
variants = %{"primary" => "admin-btn-primary", nil => "admin-btn-primary admin-btn-soft"} variants = %{
"primary" => "admin-btn-primary",
"outline" => "admin-btn-outline",
nil => "admin-btn-primary admin-btn-soft"
}
variant_class = Map.fetch!(variants, assigns[:variant])
assigns = assigns =
assign_new(assigns, :class, fn -> assign(assigns, :class, [
["admin-btn", Map.fetch!(variants, assigns[:variant])] "admin-btn",
end) variant_class,
assigns[:class]
])
if rest[:href] || rest[:navigate] || rest[:patch] do if rest[:href] || rest[:navigate] || rest[:patch] do
~H""" ~H"""

View File

@ -43,6 +43,14 @@
<%!-- nav links --%> <%!-- nav links --%>
<nav class="flex-1 p-2" aria-label="Admin navigation"> <nav class="flex-1 p-2" aria-label="Admin navigation">
<ul class="admin-nav"> <ul class="admin-nav">
<li :if={!@site_live}>
<.link
navigate={~p"/admin/setup"}
class={admin_nav_active?(@current_path, "/admin/setup")}
>
<.icon name="hero-rocket-launch" class="size-5" /> Setup
</.link>
</li>
<li> <li>
<.link <.link
navigate={~p"/admin"} navigate={~p"/admin"}

View File

@ -1,227 +1,27 @@
defmodule BerrypodWeb.Admin.Dashboard do defmodule BerrypodWeb.Admin.Dashboard do
use BerrypodWeb, :live_view use BerrypodWeb, :live_view
alias Berrypod.{Cart, Orders, Products, Settings, Setup} alias Berrypod.{Cart, Orders, Products, Settings}
alias Berrypod.Products.ProviderConnection
alias Berrypod.Providers
alias Berrypod.Stripe.Setup, as: StripeSetup
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
status = Setup.setup_status() if Settings.site_live?() do
status_counts = Orders.count_orders_by_status() status_counts = Orders.count_orders_by_status()
paid_count = Map.get(status_counts, "paid", 0) paid_count = Map.get(status_counts, "paid", 0)
recent_orders = Orders.list_orders(status: "paid") |> Enum.take(5) recent_orders = Orders.list_orders(status: "paid") |> Enum.take(5)
conn = Products.get_provider_connection_by_type("printify") {:ok,
socket
if conn && connected?(socket) do |> assign(:page_title, "Dashboard")
Phoenix.PubSub.subscribe(Berrypod.PubSub, "sync:#{conn.id}") |> assign(:paid_count, paid_count)
end |> assign(:revenue, Orders.total_revenue())
|> assign(:product_count, Products.count_products())
active_step = determine_active_step(status) |> assign(:recent_orders, recent_orders)}
{:ok,
socket
|> assign(:page_title, "Dashboard")
|> assign(:setup, status)
|> assign(:active_step, active_step)
# Printify state
|> assign(:printify_conn, conn)
|> assign(:printify_form, to_form(%{"api_key" => ""}, as: :printify))
|> assign(:printify_testing, false)
|> assign(:printify_test_result, nil)
|> assign(:printify_saving, false)
|> assign(:pending_api_key, nil)
|> assign(:sync_status, conn && conn.sync_status)
# Stripe state
|> assign(:stripe_form, to_form(%{"api_key" => ""}, as: :stripe))
|> assign(:stripe_connecting, false)
|> assign(:stripe_api_key_hint, Settings.secret_hint("stripe_api_key"))
# Celebration
|> assign(:just_went_live, false)
# Stats
|> assign(:paid_count, paid_count)
|> assign(:revenue, Orders.total_revenue())
|> assign(:recent_orders, recent_orders)}
end
# -- Step determination --
defp determine_active_step(status) do
cond do
!status.printify_connected -> :printify
!status.products_synced -> :printify
!status.stripe_connected -> :stripe
!status.site_live -> :go_live
true -> :complete
end
end
# -- Events: Printify --
@impl true
def handle_event("validate_printify", %{"printify" => params}, socket) do
{:noreply, assign(socket, pending_api_key: params["api_key"])}
end
def handle_event("test_printify", _params, socket) do
api_key = socket.assigns.pending_api_key
if api_key in [nil, ""] do
{:noreply, assign(socket, printify_test_result: {:error, :no_api_key})}
else else
socket = assign(socket, printify_testing: true, printify_test_result: nil) {:ok, push_navigate(socket, to: ~p"/admin/setup")}
temp_conn = %ProviderConnection{
provider_type: "printify",
api_key_encrypted: encrypt_api_key(api_key)
}
result = Providers.test_connection(temp_conn)
{:noreply, assign(socket, printify_testing: false, printify_test_result: result)}
end end
end end
def handle_event("connect_printify", %{"printify" => %{"api_key" => api_key}}, socket) do
if api_key == "" do
{:noreply, put_flash(socket, :error, "Please enter your Printify API token")}
else
socket = assign(socket, printify_saving: true)
params =
%{"api_key" => api_key, "provider_type" => "printify"}
|> maybe_add_shop_config(socket.assigns.printify_test_result)
|> maybe_add_name(socket.assigns.printify_test_result)
case Products.create_provider_connection(params) do
{:ok, connection} ->
Products.enqueue_sync(connection)
if connected?(socket) do
Phoenix.PubSub.subscribe(Berrypod.PubSub, "sync:#{connection.id}")
end
status = %{socket.assigns.setup | printify_connected: true}
{:noreply,
socket
|> assign(:printify_saving, false)
|> assign(:printify_conn, connection)
|> assign(:sync_status, "syncing")
|> assign(:setup, status)
|> put_flash(:info, "Connected to Printify! Syncing products...")}
{:error, _changeset} ->
{:noreply,
socket
|> assign(:printify_saving, false)
|> put_flash(:error, "Failed to save connection")}
end
end
end
def handle_event("retry_sync", _params, socket) do
conn = socket.assigns.printify_conn
if conn do
Products.enqueue_sync(conn)
{:noreply, assign(socket, sync_status: "syncing")}
else
{:noreply, socket}
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} ->
status = %{socket.assigns.setup | stripe_connected: true, can_go_live: true}
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> assign(:setup, status)
|> assign(:stripe_api_key_hint, Settings.secret_hint("stripe_api_key"))
|> assign(:active_step, :go_live)
|> put_flash(:info, "Stripe connected")}
{:error, message} ->
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> put_flash(:error, "Stripe connection failed: #{message}")}
end
end
end
# -- Events: Go live --
def handle_event("go_live", _params, socket) do
{:ok, _} = Settings.set_site_live(true)
status = %{socket.assigns.setup | site_live: true}
{:noreply,
socket
|> assign(:setup, status)
|> assign(:just_went_live, true)}
end
# -- Events: Step navigation --
def handle_event("toggle_step", %{"step" => step}, socket) do
step = String.to_existing_atom(step)
new_active =
if socket.assigns.active_step == step do
determine_active_step(socket.assigns.setup)
else
step
end
{:noreply, assign(socket, active_step: new_active)}
end
# -- PubSub: Sync progress --
@impl true
def handle_info({:sync_status, "completed", product_count}, socket) do
status = %{
socket.assigns.setup
| products_synced: true,
product_count: product_count
}
active_step = if status.stripe_connected, do: :go_live, else: :stripe
{:noreply,
socket
|> assign(:setup, status)
|> assign(:sync_status, "completed")
|> assign(:active_step, active_step)
|> put_flash(:info, "#{product_count} products synced")}
end
def handle_info({:sync_status, "failed"}, socket) do
{:noreply,
socket
|> assign(:sync_status, "failed")
|> put_flash(:error, "Product sync failed — try again")}
end
def handle_info({:sync_status, status}, socket) do
{:noreply, assign(socket, sync_status: status)}
end
# -- Render --
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -229,25 +29,6 @@ defmodule BerrypodWeb.Admin.Dashboard do
Dashboard Dashboard
</.header> </.header>
<%!-- Celebration state --%>
<.celebration :if={@just_went_live} />
<%!-- Setup stepper (when not live and not celebrating) --%>
<.setup_stepper
:if={!@setup.site_live and !@just_went_live}
setup={@setup}
active_step={@active_step}
printify_conn={@printify_conn}
printify_form={@printify_form}
printify_testing={@printify_testing}
printify_test_result={@printify_test_result}
printify_saving={@printify_saving}
sync_status={@sync_status}
stripe_form={@stripe_form}
stripe_connecting={@stripe_connecting}
stripe_api_key_hint={@stripe_api_key_hint}
/>
<%!-- Stats --%> <%!-- Stats --%>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6"> <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6">
<.stat_card <.stat_card
@ -264,7 +45,7 @@ defmodule BerrypodWeb.Admin.Dashboard do
/> />
<.stat_card <.stat_card
label="Products" label="Products"
value={@setup.product_count} value={@product_count}
icon="hero-cube" icon="hero-cube"
href={~p"/admin/products"} href={~p"/admin/products"}
/> />
@ -321,382 +102,7 @@ defmodule BerrypodWeb.Admin.Dashboard do
end end
# ========================================================================== # ==========================================================================
# Setup stepper components # Components
# ==========================================================================
attr :setup, :map, required: true
attr :active_step, :atom, required: true
attr :printify_conn, :any, required: true
attr :printify_form, :any, required: true
attr :printify_testing, :boolean, required: true
attr :printify_test_result, :any, required: true
attr :printify_saving, :boolean, required: true
attr :sync_status, :string, required: true
attr :stripe_form, :any, required: true
attr :stripe_connecting, :boolean, required: true
attr :stripe_api_key_hint, :string, required: true
defp setup_stepper(assigns) do
~H"""
<div class="mt-6">
<ol class="relative" aria-label="Setup steps">
<%!-- Step 1: Printify --%>
<.setup_step
step={:printify}
number={1}
title="Connect to Printify"
active_step={@active_step}
done={@setup.printify_connected and @setup.products_synced}
last={false}
next_done={@setup.stripe_connected}
>
<:summary :if={@setup.printify_connected and @setup.products_synced}>
Connected &middot; {@setup.product_count} products synced
</:summary>
<:content>
<.printify_step_content
setup={@setup}
printify_conn={@printify_conn}
printify_form={@printify_form}
printify_testing={@printify_testing}
printify_test_result={@printify_test_result}
printify_saving={@printify_saving}
sync_status={@sync_status}
/>
</:content>
</.setup_step>
<%!-- Step 2: Stripe --%>
<.setup_step
step={:stripe}
number={2}
title="Connect Stripe"
active_step={@active_step}
done={@setup.stripe_connected}
last={false}
next_done={@setup.site_live}
>
<:summary :if={@setup.stripe_connected}>
Connected &middot; {@stripe_api_key_hint}
</:summary>
<:content>
<.stripe_step_content
stripe_form={@stripe_form}
stripe_connecting={@stripe_connecting}
/>
</:content>
</.setup_step>
<%!-- Step 3: Go live --%>
<.setup_step
step={:go_live}
number={3}
title="Go live"
active_step={@active_step}
done={@setup.site_live}
last={true}
next_done={false}
>
<:content>
<.go_live_step_content setup={@setup} />
</:content>
</.setup_step>
</ol>
</div>
"""
end
attr :step, :atom, required: true
attr :number, :integer, required: true
attr :title, :string, required: true
attr :active_step, :atom, required: true
attr :done, :boolean, required: true
attr :last, :boolean, required: true
attr :next_done, :boolean, required: true
slot :summary
slot :content, required: true
defp setup_step(assigns) do
is_active = assigns.active_step == assigns.step
is_clickable = assigns.done
assigns =
assigns
|> assign(:is_active, is_active)
|> assign(:is_clickable, is_clickable)
~H"""
<li class="relative pl-10 pb-8 last:pb-0" aria-current={@is_active && "step"}>
<%!-- Connector line --%>
<div
:if={!@last}
class={[
"absolute left-[0.9375rem] top-8 -bottom-0 w-0.5",
if(@done, do: "bg-green-500", else: "bg-base-300")
]}
aria-hidden="true"
/>
<%!-- Step circle --%>
<div class={[
"absolute left-0 top-0 flex size-8 items-center justify-center rounded-full text-sm font-semibold ring-4 ring-base-100",
cond do
@done -> "bg-green-500 text-white"
@is_active -> "bg-base-content text-white"
true -> "bg-base-200 text-base-content/40"
end
]}>
<%= if @done do %>
<.icon name="hero-check-mini" class="size-5" />
<% else %>
{@number}
<% end %>
</div>
<%!-- Step header --%>
<%= if @is_clickable do %>
<button
type="button"
class="flex w-full items-center gap-2 text-left"
phx-click="toggle_step"
phx-value-step={@step}
aria-expanded={to_string(@is_active)}
>
<h3 class="text-sm font-semibold text-base-content">{@title}</h3>
<.icon
name={if @is_active, do: "hero-chevron-up-mini", else: "hero-chevron-down-mini"}
class="size-4 text-base-content/40"
/>
</button>
<% else %>
<h3 class={[
"text-sm font-semibold",
if(@is_active, do: "text-base-content", else: "text-base-content/40")
]}>
{@title}
</h3>
<% end %>
<%!-- Collapsed summary for completed steps --%>
<p :if={@done and !@is_active and @summary != []} class="text-sm text-base-content/60 mt-0.5">
{render_slot(@summary)}
</p>
<%!-- Expanded content --%>
<div :if={@is_active} class="mt-3">
{render_slot(@content)}
</div>
</li>
"""
end
# -- Printify step content --
attr :setup, :map, required: true
attr :printify_conn, :any, required: true
attr :printify_form, :any, required: true
attr :printify_testing, :boolean, required: true
attr :printify_test_result, :any, required: true
attr :printify_saving, :boolean, required: true
attr :sync_status, :string, required: true
defp printify_step_content(assigns) do
~H"""
<%!-- Not yet connected: show form --%>
<div :if={!@setup.printify_connected}>
<p class="text-sm text-base-content/60 mb-4">
Connect your Printify account to import products.
Get an API token from <a
href="https://printify.com/app/account/connections"
target="_blank"
rel="noopener"
class="text-base-content underline"
>
Printify &rarr; Account &rarr; Connections
</a>.
</p>
<.form
for={@printify_form}
phx-change="validate_printify"
phx-submit="connect_printify"
>
<.input
field={@printify_form[:api_key]}
type="password"
label="Printify API token"
placeholder="Paste your token here"
autocomplete="off"
/>
<div class="flex flex-col sm:flex-row gap-2 mt-3">
<button
type="button"
phx-click="test_printify"
disabled={@printify_testing}
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-base-200 px-3 py-2 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset disabled:opacity-50"
>
<.icon
name={if @printify_testing, do: "hero-arrow-path", else: "hero-signal"}
class={if @printify_testing, do: "size-4 animate-spin", else: "size-4"}
/>
{if @printify_testing, do: "Checking...", else: "Check connection"}
</button>
<.button type="submit" disabled={@printify_saving or @printify_testing}>
{if @printify_saving, do: "Connecting...", else: "Connect to Printify"}
</.button>
</div>
<.printify_test_feedback :if={@printify_test_result} result={@printify_test_result} />
</.form>
</div>
<%!-- Connected, syncing --%>
<div
:if={@setup.printify_connected and @sync_status == "syncing"}
class="flex items-center gap-2 text-sm"
>
<.icon name="hero-arrow-path" class="size-4 animate-spin text-base-content/40" />
<span class="text-base-content/60">Syncing products from Printify...</span>
</div>
<%!-- Connected, sync failed --%>
<div :if={@setup.printify_connected and @sync_status == "failed"}>
<p class="text-sm text-red-600 mb-2">Product sync failed.</p>
<button
type="button"
phx-click="retry_sync"
class="inline-flex items-center gap-1.5 rounded-md bg-base-200 px-3 py-2 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset"
>
<.icon name="hero-arrow-path" class="size-4" /> Try again
</button>
</div>
<%!-- Connected, synced (shown when user expands a completed step) --%>
<div :if={@setup.printify_connected and @setup.products_synced}>
<p class="text-sm text-base-content/60">
{@setup.product_count} products synced from Printify.
</p>
</div>
"""
end
attr :result, :any, required: true
defp printify_test_feedback(assigns) do
~H"""
<div class="mt-2 text-sm">
<%= case @result do %>
<% {:ok, info} -> %>
<span class="text-green-600 flex items-center gap-1">
<.icon name="hero-check-circle" class="size-4" /> Connected to {info.shop_name}
</span>
<% {:error, reason} -> %>
<span class="text-red-600 flex items-center gap-1">
<.icon name="hero-x-circle" class="size-4" />
{format_printify_error(reason)}
</span>
<% end %>
</div>
"""
end
# -- Stripe step content --
attr :stripe_form, :any, required: true
attr :stripe_connecting, :boolean, required: true
defp stripe_step_content(assigns) do
~H"""
<div>
<p class="text-sm text-base-content/60 mb-4">
Enter your Stripe secret key to accept payments.
Find it in your
<a
href="https://dashboard.stripe.com/apikeys"
target="_blank"
rel="noopener"
class="text-base-content underline"
>
Stripe dashboard
</a>
under Developers &rarr; API keys.
</p>
<.form for={@stripe_form} phx-submit="connect_stripe">
<.input
field={@stripe_form[:api_key]}
type="password"
label="Secret key"
autocomplete="off"
placeholder="sk_test_... or sk_live_..."
/>
<p class="text-xs text-base-content/60 mt-1">
Starts with <code>sk_test_</code> or <code>sk_live_</code>. Encrypted at rest.
</p>
<div class="mt-3">
<.button phx-disable-with="Connecting...">
{if @stripe_connecting, do: "Connecting...", else: "Connect Stripe"}
</.button>
</div>
</.form>
</div>
"""
end
# -- Go live step content --
attr :setup, :map, required: true
defp go_live_step_content(assigns) do
~H"""
<div>
<p class="text-sm text-base-content/60 mb-4">
Your shop is ready. Visitors currently see a "coming soon" page &mdash;
hit the button to make it live.
</p>
<button
phx-click="go_live"
disabled={!@setup.can_go_live}
class="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-green-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<.icon name="hero-rocket-launch" class="size-5" /> Go live
</button>
</div>
"""
end
# -- Celebration --
defp celebration(assigns) do
~H"""
<div class="mt-6 rounded-lg border border-green-200 bg-green-50 p-6 text-center">
<.icon name="hero-check-badge" class="size-12 mx-auto text-green-600 mb-3" />
<h2 class="text-lg font-semibold text-green-900">Your shop is live!</h2>
<p class="text-sm text-green-700 mt-1 mb-4">
Customers can now browse and buy from your shop.
</p>
<div class="flex flex-col sm:flex-row gap-2 justify-center">
<.link
navigate={~p"/"}
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white hover:bg-green-500"
>
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> View your shop
</.link>
<.link
navigate={~p"/admin/theme"}
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-base-100 px-3 py-2 text-sm font-medium text-base-content ring-1 ring-base-300 ring-inset hover:bg-base-200/50"
>
<.icon name="hero-paint-brush-mini" class="size-4" /> Customise theme
</.link>
</div>
</div>
"""
end
# ==========================================================================
# Stats components
# ========================================================================== # ==========================================================================
attr :label, :string, required: true attr :label, :string, required: true
@ -757,31 +163,4 @@ defmodule BerrypodWeb.Admin.Dashboard do
defp format_date(datetime) do defp format_date(datetime) do
Calendar.strftime(datetime, "%d %b %Y") Calendar.strftime(datetime, "%d %b %Y")
end end
defp encrypt_api_key(api_key) do
case Berrypod.Vault.encrypt(api_key) do
{:ok, encrypted} -> encrypted
_ -> nil
end
end
defp maybe_add_shop_config(params, {:ok, %{shop_id: shop_id}}) do
config = Map.get(params, "config", %{}) |> Map.put("shop_id", to_string(shop_id))
Map.put(params, "config", config)
end
defp maybe_add_shop_config(params, _), do: params
defp maybe_add_name(params, {:ok, %{shop_name: shop_name}}) when is_binary(shop_name) do
Map.put_new(params, "name", shop_name)
end
defp maybe_add_name(params, _), do: Map.put_new(params, "name", "Printify")
defp format_printify_error(:no_api_key), do: "Please enter your API token"
defp format_printify_error(:unauthorized), do: "That token doesn't seem to be valid"
defp format_printify_error(:timeout), do: "Couldn't reach Printify — try again"
defp format_printify_error({:http_error, _code}), do: "Something went wrong — try again"
defp format_printify_error(error) when is_binary(error), do: error
defp format_printify_error(_), do: "Connection failed — check your token and try again"
end end

View File

@ -203,7 +203,7 @@ defmodule BerrypodWeb.Admin.ProductShow do
placeholder="e.g. Apparel" placeholder="e.g. Apparel"
/> />
</label> </label>
<.button type="submit" class="admin-btn-sm admin-btn-primary">Save</.button> <.button type="submit" variant="primary" class="admin-btn-sm">Save</.button>
</.form> </.form>
</div> </div>
</div> </div>

View File

@ -29,7 +29,7 @@
<.button navigate={~p"/admin/providers/new?type=printify"}> <.button navigate={~p"/admin/providers/new?type=printify"}>
Connect Printify Connect Printify
</.button> </.button>
<.button navigate={~p"/admin/providers/new?type=printful"} class="admin-btn-outline"> <.button navigate={~p"/admin/providers/new?type=printful"} variant="outline">
Connect Printful Connect Printful
</.button> </.button>
</div> </div>

View File

@ -0,0 +1,660 @@
defmodule BerrypodWeb.Admin.Setup do
use BerrypodWeb, :live_view
alias Berrypod.{Products, Settings, Setup}
alias Berrypod.Products.ProviderConnection
alias Berrypod.Providers
alias Berrypod.Stripe.Setup, as: StripeSetup
@impl true
def mount(_params, _session, socket) do
if Settings.site_live?() do
{:ok, push_navigate(socket, to: ~p"/admin")}
else
status = Setup.setup_status()
conn = Products.get_provider_connection_by_type("printify")
if conn && connected?(socket) do
Phoenix.PubSub.subscribe(Berrypod.PubSub, "sync:#{conn.id}")
end
active_step = determine_active_step(status)
{:ok,
socket
|> assign(:page_title, "Get started")
|> assign(:setup, status)
|> assign(:active_step, active_step)
# Printify state
|> assign(:printify_conn, conn)
|> assign(:printify_form, to_form(%{"api_key" => ""}, as: :printify))
|> assign(:printify_testing, false)
|> assign(:printify_test_result, nil)
|> assign(:printify_saving, false)
|> assign(:pending_api_key, nil)
|> assign(:sync_status, conn && conn.sync_status)
# Stripe state
|> assign(:stripe_form, to_form(%{"api_key" => ""}, as: :stripe))
|> assign(:stripe_connecting, false)
|> assign(:stripe_api_key_hint, Settings.secret_hint("stripe_api_key"))
# Celebration
|> assign(:just_went_live, false)}
end
end
# -- Step determination --
defp determine_active_step(status) do
cond do
!status.printify_connected -> :printify
!status.products_synced -> :printify
!status.stripe_connected -> :stripe
!status.site_live -> :go_live
true -> :complete
end
end
# -- Events: Printify --
@impl true
def handle_event("validate_printify", %{"printify" => params}, socket) do
{:noreply, assign(socket, pending_api_key: params["api_key"])}
end
def handle_event("test_printify", _params, socket) do
api_key = socket.assigns.pending_api_key
if api_key in [nil, ""] do
{:noreply, assign(socket, printify_test_result: {:error, :no_api_key})}
else
socket = assign(socket, printify_testing: true, printify_test_result: nil)
temp_conn = %ProviderConnection{
provider_type: "printify",
api_key_encrypted: encrypt_api_key(api_key)
}
result = Providers.test_connection(temp_conn)
{:noreply, assign(socket, printify_testing: false, printify_test_result: result)}
end
end
def handle_event("connect_printify", %{"printify" => %{"api_key" => api_key}}, socket) do
if api_key == "" do
{:noreply, put_flash(socket, :error, "Please enter your Printify API token")}
else
socket = assign(socket, printify_saving: true)
params =
%{"api_key" => api_key, "provider_type" => "printify"}
|> maybe_add_shop_config(socket.assigns.printify_test_result)
|> maybe_add_name(socket.assigns.printify_test_result)
case Products.create_provider_connection(params) do
{:ok, connection} ->
Products.enqueue_sync(connection)
if connected?(socket) do
Phoenix.PubSub.subscribe(Berrypod.PubSub, "sync:#{connection.id}")
end
status = %{socket.assigns.setup | printify_connected: true}
{:noreply,
socket
|> assign(:printify_saving, false)
|> assign(:printify_conn, connection)
|> assign(:sync_status, "syncing")
|> assign(:setup, status)
|> put_flash(:info, "Connected to Printify! Syncing products...")}
{:error, _changeset} ->
{:noreply,
socket
|> assign(:printify_saving, false)
|> put_flash(:error, "Failed to save connection")}
end
end
end
def handle_event("retry_sync", _params, socket) do
conn = socket.assigns.printify_conn
if conn do
Products.enqueue_sync(conn)
{:noreply, assign(socket, sync_status: "syncing")}
else
{:noreply, socket}
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} ->
status = %{socket.assigns.setup | stripe_connected: true, can_go_live: true}
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> assign(:setup, status)
|> assign(:stripe_api_key_hint, Settings.secret_hint("stripe_api_key"))
|> assign(:active_step, :go_live)
|> put_flash(:info, "Stripe connected")}
{:error, message} ->
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> put_flash(:error, "Stripe connection failed: #{message}")}
end
end
end
# -- Events: Go live --
def handle_event("go_live", _params, socket) do
{:ok, _} = Settings.set_site_live(true)
status = %{socket.assigns.setup | site_live: true}
{:noreply,
socket
|> assign(:setup, status)
|> assign(:just_went_live, true)}
end
# -- Events: Step navigation --
def handle_event("toggle_step", %{"step" => step}, socket) do
step = String.to_existing_atom(step)
new_active =
if socket.assigns.active_step == step do
determine_active_step(socket.assigns.setup)
else
step
end
{:noreply, assign(socket, active_step: new_active)}
end
# -- PubSub: Sync progress --
@impl true
def handle_info({:sync_status, "completed", product_count}, socket) do
status = %{
socket.assigns.setup
| products_synced: true,
product_count: product_count
}
active_step = if status.stripe_connected, do: :go_live, else: :stripe
{:noreply,
socket
|> assign(:setup, status)
|> assign(:sync_status, "completed")
|> assign(:active_step, active_step)
|> put_flash(:info, "#{product_count} products synced")}
end
def handle_info({:sync_status, "failed"}, socket) do
{:noreply,
socket
|> assign(:sync_status, "failed")
|> put_flash(:error, "Product sync failed — try again")}
end
def handle_info({:sync_status, status}, socket) do
{:noreply, assign(socket, sync_status: status)}
end
# -- Render --
@impl true
def render(assigns) do
~H"""
<.header>
Get started
</.header>
<%!-- Celebration state --%>
<.celebration :if={@just_went_live} />
<%!-- Setup stepper --%>
<.setup_stepper
:if={!@just_went_live}
setup={@setup}
active_step={@active_step}
printify_conn={@printify_conn}
printify_form={@printify_form}
printify_testing={@printify_testing}
printify_test_result={@printify_test_result}
printify_saving={@printify_saving}
sync_status={@sync_status}
stripe_form={@stripe_form}
stripe_connecting={@stripe_connecting}
stripe_api_key_hint={@stripe_api_key_hint}
/>
"""
end
# ==========================================================================
# Setup stepper components
# ==========================================================================
attr :setup, :map, required: true
attr :active_step, :atom, required: true
attr :printify_conn, :any, required: true
attr :printify_form, :any, required: true
attr :printify_testing, :boolean, required: true
attr :printify_test_result, :any, required: true
attr :printify_saving, :boolean, required: true
attr :sync_status, :string, required: true
attr :stripe_form, :any, required: true
attr :stripe_connecting, :boolean, required: true
attr :stripe_api_key_hint, :string, required: true
defp setup_stepper(assigns) do
~H"""
<div class="mt-6">
<ol class="relative" aria-label="Setup steps">
<%!-- Step 1: Printify --%>
<.setup_step
step={:printify}
number={1}
title="Connect to Printify"
active_step={@active_step}
done={@setup.printify_connected and @setup.products_synced}
last={false}
next_done={@setup.stripe_connected}
>
<:summary :if={@setup.printify_connected and @setup.products_synced}>
Connected &middot; {@setup.product_count} products synced
</:summary>
<:content>
<.printify_step_content
setup={@setup}
printify_conn={@printify_conn}
printify_form={@printify_form}
printify_testing={@printify_testing}
printify_test_result={@printify_test_result}
printify_saving={@printify_saving}
sync_status={@sync_status}
/>
</:content>
</.setup_step>
<%!-- Step 2: Stripe --%>
<.setup_step
step={:stripe}
number={2}
title="Connect Stripe"
active_step={@active_step}
done={@setup.stripe_connected}
last={false}
next_done={@setup.site_live}
>
<:summary :if={@setup.stripe_connected}>
Connected &middot; {@stripe_api_key_hint}
</:summary>
<:content>
<.stripe_step_content
stripe_form={@stripe_form}
stripe_connecting={@stripe_connecting}
/>
</:content>
</.setup_step>
<%!-- Step 3: Go live --%>
<.setup_step
step={:go_live}
number={3}
title="Go live"
active_step={@active_step}
done={@setup.site_live}
last={true}
next_done={false}
>
<:content>
<.go_live_step_content setup={@setup} />
</:content>
</.setup_step>
</ol>
</div>
"""
end
attr :step, :atom, required: true
attr :number, :integer, required: true
attr :title, :string, required: true
attr :active_step, :atom, required: true
attr :done, :boolean, required: true
attr :last, :boolean, required: true
attr :next_done, :boolean, required: true
slot :summary
slot :content, required: true
defp setup_step(assigns) do
is_active = assigns.active_step == assigns.step
is_clickable = assigns.done
assigns =
assigns
|> assign(:is_active, is_active)
|> assign(:is_clickable, is_clickable)
~H"""
<li class="relative pl-10 pb-8 last:pb-0" aria-current={@is_active && "step"}>
<%!-- Connector line --%>
<div
:if={!@last}
class={[
"absolute left-[0.9375rem] top-8 -bottom-0 w-0.5",
if(@done, do: "bg-green-500", else: "bg-base-300")
]}
aria-hidden="true"
/>
<%!-- Step circle --%>
<div class={[
"absolute left-0 top-0 flex size-8 items-center justify-center rounded-full text-sm font-semibold ring-4 ring-base-100",
cond do
@done -> "bg-green-500 text-white"
@is_active -> "bg-base-content text-white"
true -> "bg-base-200 text-base-content/40"
end
]}>
<%= if @done do %>
<.icon name="hero-check-mini" class="size-5" />
<% else %>
{@number}
<% end %>
</div>
<%!-- Step header --%>
<%= if @is_clickable do %>
<button
type="button"
class="flex w-full items-center gap-2 text-left"
phx-click="toggle_step"
phx-value-step={@step}
aria-expanded={to_string(@is_active)}
>
<h3 class="text-sm font-semibold text-base-content">{@title}</h3>
<.icon
name={if @is_active, do: "hero-chevron-up-mini", else: "hero-chevron-down-mini"}
class="size-4 text-base-content/40"
/>
</button>
<% else %>
<h3 class={[
"text-sm font-semibold",
if(@is_active, do: "text-base-content", else: "text-base-content/40")
]}>
{@title}
</h3>
<% end %>
<%!-- Collapsed summary for completed steps --%>
<p :if={@done and !@is_active and @summary != []} class="text-sm text-base-content/60 mt-0.5">
{render_slot(@summary)}
</p>
<%!-- Expanded content --%>
<div :if={@is_active} class="mt-3">
{render_slot(@content)}
</div>
</li>
"""
end
# -- Printify step content --
attr :setup, :map, required: true
attr :printify_conn, :any, required: true
attr :printify_form, :any, required: true
attr :printify_testing, :boolean, required: true
attr :printify_test_result, :any, required: true
attr :printify_saving, :boolean, required: true
attr :sync_status, :string, required: true
defp printify_step_content(assigns) do
~H"""
<%!-- Not yet connected: show form --%>
<div :if={!@setup.printify_connected}>
<p class="text-sm text-base-content/60 mb-4">
Connect your Printify account to import products.
Get an API token from <a
href="https://printify.com/app/account/connections"
target="_blank"
rel="noopener"
class="text-base-content underline"
>
Printify &rarr; Account &rarr; Connections
</a>.
</p>
<.form
for={@printify_form}
phx-change="validate_printify"
phx-submit="connect_printify"
>
<.input
field={@printify_form[:api_key]}
type="password"
label="Printify API token"
placeholder="Paste your token here"
autocomplete="off"
/>
<div class="flex flex-col sm:flex-row gap-2 mt-3">
<button
type="button"
phx-click="test_printify"
disabled={@printify_testing}
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-base-200 px-3 py-2 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset disabled:opacity-50"
>
<.icon
name={if @printify_testing, do: "hero-arrow-path", else: "hero-signal"}
class={if @printify_testing, do: "size-4 animate-spin", else: "size-4"}
/>
{if @printify_testing, do: "Checking...", else: "Check connection"}
</button>
<.button type="submit" disabled={@printify_saving or @printify_testing}>
{if @printify_saving, do: "Connecting...", else: "Connect to Printify"}
</.button>
</div>
<.printify_test_feedback :if={@printify_test_result} result={@printify_test_result} />
</.form>
</div>
<%!-- Connected, syncing --%>
<div
:if={@setup.printify_connected and @sync_status == "syncing"}
class="flex items-center gap-2 text-sm"
>
<.icon name="hero-arrow-path" class="size-4 animate-spin text-base-content/40" />
<span class="text-base-content/60">Syncing products from Printify...</span>
</div>
<%!-- Connected, sync failed --%>
<div :if={@setup.printify_connected and @sync_status == "failed"}>
<p class="text-sm text-red-600 mb-2">Product sync failed.</p>
<button
type="button"
phx-click="retry_sync"
class="inline-flex items-center gap-1.5 rounded-md bg-base-200 px-3 py-2 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset"
>
<.icon name="hero-arrow-path" class="size-4" /> Try again
</button>
</div>
<%!-- Connected, synced (shown when user expands a completed step) --%>
<div :if={@setup.printify_connected and @setup.products_synced}>
<p class="text-sm text-base-content/60">
{@setup.product_count} products synced from Printify.
</p>
</div>
"""
end
attr :result, :any, required: true
defp printify_test_feedback(assigns) do
~H"""
<div class="mt-2 text-sm">
<%= case @result do %>
<% {:ok, info} -> %>
<span class="text-green-600 flex items-center gap-1">
<.icon name="hero-check-circle" class="size-4" /> Connected to {info.shop_name}
</span>
<% {:error, reason} -> %>
<span class="text-red-600 flex items-center gap-1">
<.icon name="hero-x-circle" class="size-4" />
{format_printify_error(reason)}
</span>
<% end %>
</div>
"""
end
# -- Stripe step content --
attr :stripe_form, :any, required: true
attr :stripe_connecting, :boolean, required: true
defp stripe_step_content(assigns) do
~H"""
<div>
<p class="text-sm text-base-content/60 mb-4">
Enter your Stripe secret key to accept payments.
Find it in your
<a
href="https://dashboard.stripe.com/apikeys"
target="_blank"
rel="noopener"
class="text-base-content underline"
>
Stripe dashboard
</a>
under Developers &rarr; API keys.
</p>
<.form for={@stripe_form} phx-submit="connect_stripe">
<.input
field={@stripe_form[:api_key]}
type="password"
label="Secret key"
autocomplete="off"
placeholder="sk_test_... or sk_live_..."
/>
<p class="text-xs text-base-content/60 mt-1">
Starts with <code>sk_test_</code> or <code>sk_live_</code>. Encrypted at rest.
</p>
<div class="mt-3">
<.button phx-disable-with="Connecting...">
{if @stripe_connecting, do: "Connecting...", else: "Connect Stripe"}
</.button>
</div>
</.form>
</div>
"""
end
# -- Go live step content --
attr :setup, :map, required: true
defp go_live_step_content(assigns) do
~H"""
<div>
<p class="text-sm text-base-content/60 mb-4">
Your shop is ready. Visitors currently see a "coming soon" page &mdash;
hit the button to make it live.
</p>
<button
phx-click="go_live"
disabled={!@setup.can_go_live}
class="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2.5 text-sm font-semibold text-white shadow-xs hover:bg-green-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<.icon name="hero-rocket-launch" class="size-5" /> Go live
</button>
</div>
"""
end
# -- Celebration --
defp celebration(assigns) do
~H"""
<div class="mt-6 rounded-lg border border-green-200 bg-green-50 p-6 text-center">
<.icon name="hero-check-badge" class="size-12 mx-auto text-green-600 mb-3" />
<h2 class="text-lg font-semibold text-green-900">Your shop is live!</h2>
<p class="text-sm text-green-700 mt-1 mb-4">
Customers can now browse and buy from your shop.
</p>
<div class="flex flex-col sm:flex-row gap-2 justify-center">
<.link
navigate={~p"/admin"}
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-green-600 px-3 py-2 text-sm font-semibold text-white hover:bg-green-500"
>
<.icon name="hero-home-mini" class="size-4" /> Go to dashboard
</.link>
<.link
navigate={~p"/"}
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-base-100 px-3 py-2 text-sm font-medium text-base-content ring-1 ring-base-300 ring-inset hover:bg-base-200/50"
>
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> View your shop
</.link>
<.link
navigate={~p"/admin/theme"}
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-base-100 px-3 py-2 text-sm font-medium text-base-content ring-1 ring-base-300 ring-inset hover:bg-base-200/50"
>
<.icon name="hero-paint-brush-mini" class="size-4" /> Customise theme
</.link>
</div>
</div>
"""
end
# ==========================================================================
# Helpers
# ==========================================================================
defp encrypt_api_key(api_key) do
case Berrypod.Vault.encrypt(api_key) do
{:ok, encrypted} -> encrypted
_ -> nil
end
end
defp maybe_add_shop_config(params, {:ok, %{shop_id: shop_id}}) do
config = Map.get(params, "config", %{}) |> Map.put("shop_id", to_string(shop_id))
Map.put(params, "config", config)
end
defp maybe_add_shop_config(params, _), do: params
defp maybe_add_name(params, {:ok, %{shop_name: shop_name}}) when is_binary(shop_name) do
Map.put_new(params, "name", shop_name)
end
defp maybe_add_name(params, _), do: Map.put_new(params, "name", "Printify")
defp format_printify_error(:no_api_key), do: "Please enter your API token"
defp format_printify_error(:unauthorized), do: "That token doesn't seem to be valid"
defp format_printify_error(:timeout), do: "Couldn't reach Printify — try again"
defp format_printify_error({:http_error, _code}), do: "Something went wrong — try again"
defp format_printify_error(error) when is_binary(error), do: error
defp format_printify_error(_), do: "Connection failed — check your token and try again"
end

View File

@ -26,11 +26,12 @@ defmodule BerrypodWeb.Auth.Confirmation do
name={@form[:remember_me].name} name={@form[:remember_me].name}
value="true" value="true"
phx-disable-with="Confirming..." phx-disable-with="Confirming..."
class="admin-btn-primary w-full" variant="primary"
class="w-full"
> >
Confirm and stay logged in Confirm and stay logged in
</.button> </.button>
<.button phx-disable-with="Confirming..." class="admin-btn-soft w-full mt-2"> <.button phx-disable-with="Confirming..." class="w-full mt-2">
Confirm and log in only this time Confirm and log in only this time
</.button> </.button>
</.form> </.form>
@ -46,7 +47,7 @@ defmodule BerrypodWeb.Auth.Confirmation do
> >
<input type="hidden" name={@form[:token].name} value={@form[:token].value} /> <input type="hidden" name={@form[:token].name} value={@form[:token].value} />
<%= if @current_scope do %> <%= if @current_scope do %>
<.button phx-disable-with="Logging in..." class="admin-btn-primary w-full"> <.button phx-disable-with="Logging in..." variant="primary" class="w-full">
Log in Log in
</.button> </.button>
<% else %> <% else %>
@ -54,11 +55,12 @@ defmodule BerrypodWeb.Auth.Confirmation do
name={@form[:remember_me].name} name={@form[:remember_me].name}
value="true" value="true"
phx-disable-with="Logging in..." phx-disable-with="Logging in..."
class="admin-btn-primary w-full" variant="primary"
class="w-full"
> >
Keep me logged in on this device Keep me logged in on this device
</.button> </.button>
<.button phx-disable-with="Logging in..." class="admin-btn-soft w-full mt-2"> <.button phx-disable-with="Logging in..." class="w-full mt-2">
Log me in only this time Log me in only this time
</.button> </.button>
<% end %> <% end %>

View File

@ -51,7 +51,7 @@ defmodule BerrypodWeb.Auth.Login do
required required
phx-mounted={JS.focus()} phx-mounted={JS.focus()}
/> />
<.button class="admin-btn-primary w-full"> <.button variant="primary" class="w-full">
Log in with email <span aria-hidden="true"></span> Log in with email <span aria-hidden="true"></span>
</.button> </.button>
</.form> </.form>
@ -80,10 +80,10 @@ defmodule BerrypodWeb.Auth.Login do
label="Password" label="Password"
autocomplete="current-password" autocomplete="current-password"
/> />
<.button class="admin-btn-primary w-full" name={@form[:remember_me].name} value="true"> <.button variant="primary" class="w-full" name={@form[:remember_me].name} value="true">
Log in and stay logged in <span aria-hidden="true"></span> Log in and stay logged in <span aria-hidden="true"></span>
</.button> </.button>
<.button class="admin-btn-soft w-full mt-2"> <.button class="w-full mt-2">
Log in only this time Log in only this time
</.button> </.button>
</.form> </.form>

View File

@ -32,7 +32,7 @@ defmodule BerrypodWeb.Auth.Registration do
phx-mounted={JS.focus()} phx-mounted={JS.focus()}
/> />
<.button phx-disable-with="Creating account..." class="admin-btn-primary w-full"> <.button phx-disable-with="Creating account..." variant="primary" class="w-full">
Create an account Create an account
</.button> </.button>
</.form> </.form>

View File

@ -153,6 +153,7 @@ defmodule BerrypodWeb.Router do
{BerrypodWeb.AdminLayoutHook, :assign_current_path} {BerrypodWeb.AdminLayoutHook, :assign_current_path}
] do ] do
live "/", Admin.Dashboard, :index live "/", Admin.Dashboard, :index
live "/setup", Admin.Setup, :index
live "/orders", Admin.Orders, :index live "/orders", Admin.Orders, :index
live "/orders/:id", Admin.OrderShow, :show live "/orders/:id", Admin.OrderShow, :show
live "/products", Admin.Products, :index live "/products", Admin.Products, :index

View File

@ -257,8 +257,9 @@ defmodule BerrypodWeb.UserAuth do
end end
@doc "Returns the path to redirect to after log in." @doc "Returns the path to redirect to after log in."
# Single-tenant: every user is the admin, always go to dashboard def signed_in_path(_) do
def signed_in_path(_), do: ~p"/admin" if Berrypod.Settings.site_live?(), do: ~p"/admin", else: ~p"/admin/setup"
end
@doc """ @doc """
Plug for routes that require the user to be authenticated. Plug for routes that require the user to be authenticated.

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@ defmodule BerrypodWeb.UserSessionControllerTest do
}) })
assert get_session(conn, :user_token) assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/admin" assert redirected_to(conn) == ~p"/admin/setup"
# Now do a logged in request and assert on the page content # Now do a logged in request and assert on the page content
conn = get(conn, ~p"/admin/settings") conn = get(conn, ~p"/admin/settings")
@ -39,7 +39,7 @@ defmodule BerrypodWeb.UserSessionControllerTest do
}) })
assert conn.resp_cookies["_berrypod_web_user_remember_me"] assert conn.resp_cookies["_berrypod_web_user_remember_me"]
assert redirected_to(conn) == ~p"/admin" assert redirected_to(conn) == ~p"/admin/setup"
end end
test "logs the user in with return to", %{conn: conn, user: user} do test "logs the user in with return to", %{conn: conn, user: user} do
@ -80,7 +80,7 @@ defmodule BerrypodWeb.UserSessionControllerTest do
}) })
assert get_session(conn, :user_token) assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/admin" assert redirected_to(conn) == ~p"/admin/setup"
# Now do a logged in request and assert on the page content # Now do a logged in request and assert on the page content
conn = get(conn, ~p"/admin/settings") conn = get(conn, ~p"/admin/settings")
@ -99,7 +99,7 @@ defmodule BerrypodWeb.UserSessionControllerTest do
}) })
assert get_session(conn, :user_token) assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/admin" assert redirected_to(conn) == ~p"/admin/setup"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User confirmed successfully." assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User confirmed successfully."
assert Accounts.get_user!(user.id).confirmed_at assert Accounts.get_user!(user.id).confirmed_at

View File

@ -4,7 +4,6 @@ defmodule BerrypodWeb.Admin.DashboardTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures import Berrypod.AccountsFixtures
import Berrypod.OrdersFixtures import Berrypod.OrdersFixtures
import Berrypod.ProductsFixtures
setup do setup do
user = user_fixture() user = user_fixture()
@ -19,77 +18,20 @@ defmodule BerrypodWeb.Admin.DashboardTest do
end end
end end
describe "setup stepper" do describe "redirects to setup when not live" do
setup %{conn: conn, user: user} do setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)} %{conn: log_in_user(conn, user)}
end end
test "shows stepper with printify form when nothing connected", %{conn: conn} do test "redirects to /admin/setup when site not live", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin") {:error, redirect} = live(conn, ~p"/admin")
assert {:live_redirect, %{to: "/admin/setup"}} = redirect
assert html =~ "Setup steps"
assert html =~ "Connect to Printify"
assert html =~ "Printify API token"
assert html =~ "Connect Stripe"
assert html =~ "Go live"
end
test "shows stripe form when printify is done", %{conn: conn} do
conn_fixture = provider_connection_fixture(%{provider_type: "printify"})
_product = product_fixture(%{provider_connection: conn_fixture})
{:ok, view, _html} = live(conn, ~p"/admin")
# Printify step should be completed
assert has_element?(view, "li:first-child [class*='bg-green-500']")
# Stripe step should be active with form
assert has_element?(view, "label", "Secret key")
end
test "shows go live button when all services connected", %{conn: conn} do
conn_fixture = provider_connection_fixture(%{provider_type: "printify"})
_product = product_fixture(%{provider_connection: conn_fixture})
{:ok, _} = Berrypod.Settings.put_secret("stripe_api_key", "sk_test_123")
{:ok, view, _html} = live(conn, ~p"/admin")
assert has_element?(view, "button", "Go live")
end
test "go live shows celebration", %{conn: conn} do
conn_fixture = provider_connection_fixture(%{provider_type: "printify"})
_product = product_fixture(%{provider_connection: conn_fixture})
{:ok, _} = Berrypod.Settings.put_secret("stripe_api_key", "sk_test_123")
{:ok, view, _html} = live(conn, ~p"/admin")
html = view |> element("button", "Go live") |> render_click()
assert html =~ "Your shop is live!"
assert html =~ "View your shop"
assert html =~ "Customise theme"
end
test "hides stepper when shop is live", %{conn: conn} do
{:ok, _} = Berrypod.Settings.set_site_live(true)
{:ok, _view, html} = live(conn, ~p"/admin")
refute html =~ "Setup steps"
refute html =~ "Printify API token"
end
test "completed steps show summary and are collapsible", %{conn: conn} do
conn_fixture = provider_connection_fixture(%{provider_type: "printify"})
_product = product_fixture(%{provider_connection: conn_fixture})
{:ok, _view, html} = live(conn, ~p"/admin")
assert html =~ "products synced"
end end
end end
describe "stats" do describe "stats" do
setup %{conn: conn, user: user} do setup %{conn: conn, user: user} do
{:ok, _} = Berrypod.Settings.set_site_live(true)
%{conn: log_in_user(conn, user)} %{conn: log_in_user(conn, user)}
end end

View File

@ -30,10 +30,10 @@ defmodule BerrypodWeb.Admin.LayoutTest do
refute has_element?(view, ~s(a.active[href="/admin/settings"])) refute has_element?(view, ~s(a.active[href="/admin/settings"]))
end end
test "highlights dashboard on dashboard page", %{conn: conn} do test "highlights setup on setup page", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin") {:ok, view, _html} = live(conn, ~p"/admin/setup")
assert has_element?(view, ~s(a.active[href="/admin"])) assert has_element?(view, ~s(a.active[href="/admin/setup"]))
refute has_element?(view, ~s(a.active[href="/admin/orders"])) refute has_element?(view, ~s(a.active[href="/admin/orders"]))
end end

View File

@ -0,0 +1,88 @@
defmodule BerrypodWeb.Admin.SetupTest do
use BerrypodWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures
import Berrypod.ProductsFixtures
setup do
user = user_fixture()
%{user: user}
end
describe "unauthenticated" do
test "redirects to login", %{conn: conn} do
{:error, redirect} = live(conn, ~p"/admin/setup")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
describe "setup stepper" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user)}
end
test "shows stepper with printify form when nothing connected", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/setup")
assert html =~ "Setup steps"
assert html =~ "Connect to Printify"
assert html =~ "Printify API token"
assert html =~ "Connect Stripe"
assert html =~ "Go live"
end
test "shows stripe form when printify is done", %{conn: conn} do
conn_fixture = provider_connection_fixture(%{provider_type: "printify"})
_product = product_fixture(%{provider_connection: conn_fixture})
{:ok, view, _html} = live(conn, ~p"/admin/setup")
# Printify step should be completed
assert has_element?(view, "li:first-child [class*='bg-green-500']")
# Stripe step should be active with form
assert has_element?(view, "label", "Secret key")
end
test "shows go live button when all services connected", %{conn: conn} do
conn_fixture = provider_connection_fixture(%{provider_type: "printify"})
_product = product_fixture(%{provider_connection: conn_fixture})
{:ok, _} = Berrypod.Settings.put_secret("stripe_api_key", "sk_test_123")
{:ok, view, _html} = live(conn, ~p"/admin/setup")
assert has_element?(view, "button", "Go live")
end
test "go live shows celebration", %{conn: conn} do
conn_fixture = provider_connection_fixture(%{provider_type: "printify"})
_product = product_fixture(%{provider_connection: conn_fixture})
{:ok, _} = Berrypod.Settings.put_secret("stripe_api_key", "sk_test_123")
{:ok, view, _html} = live(conn, ~p"/admin/setup")
html = view |> element("button", "Go live") |> render_click()
assert html =~ "Your shop is live!"
assert html =~ "Go to dashboard"
assert html =~ "View your shop"
assert html =~ "Customise theme"
end
test "redirects to /admin when site is live", %{conn: conn} do
{:ok, _} = Berrypod.Settings.set_site_live(true)
{:error, redirect} = live(conn, ~p"/admin/setup")
assert {:live_redirect, %{to: "/admin"}} = redirect
end
test "completed steps show summary and are collapsible", %{conn: conn} do
conn_fixture = provider_connection_fixture(%{provider_type: "printify"})
_product = product_fixture(%{provider_connection: conn_fixture})
{:ok, _view, html} = live(conn, ~p"/admin/setup")
assert html =~ "products synced"
end
end
end

View File

@ -64,7 +64,7 @@ defmodule BerrypodWeb.Auth.ConfirmationTest do
assert Accounts.get_user!(user.id).confirmed_at assert Accounts.get_user!(user.id).confirmed_at
# we are logged in now # we are logged in now
assert get_session(conn, :user_token) assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/admin" assert redirected_to(conn) == ~p"/admin/setup"
# log out, new conn # log out, new conn
conn = build_conn() conn = build_conn()

View File

@ -56,7 +56,7 @@ defmodule BerrypodWeb.Auth.LoginTest do
conn = submit_form(form, conn) conn = submit_form(form, conn)
assert redirected_to(conn) == ~p"/admin" assert redirected_to(conn) == ~p"/admin/setup"
end end
test "redirects to login page with a flash error if credentials are invalid", %{ test "redirects to login page with a flash error if credentials are invalid", %{

View File

@ -25,7 +25,7 @@ defmodule BerrypodWeb.Auth.RegistrationTest do
conn conn
|> log_in_user(user_fixture()) |> log_in_user(user_fixture())
|> live(~p"/users/register") |> live(~p"/users/register")
|> follow_redirect(conn, ~p"/admin") |> follow_redirect(conn, ~p"/admin/setup")
assert {:ok, _conn} = result assert {:ok, _conn} = result
end end

View File

@ -25,7 +25,7 @@ defmodule BerrypodWeb.UserAuthTest do
conn = UserAuth.log_in_user(conn, user) conn = UserAuth.log_in_user(conn, user)
assert token = get_session(conn, :user_token) assert token = get_session(conn, :user_token)
assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}"
assert redirected_to(conn) == ~p"/admin" assert redirected_to(conn) == ~p"/admin/setup"
assert Accounts.get_user_by_session_token(token) assert Accounts.get_user_by_session_token(token)
end end
@ -80,7 +80,7 @@ defmodule BerrypodWeb.UserAuthTest do
|> assign(:current_scope, Scope.for_user(user)) |> assign(:current_scope, Scope.for_user(user))
|> UserAuth.log_in_user(user) |> UserAuth.log_in_user(user)
assert redirected_to(conn) == ~p"/admin" assert redirected_to(conn) == ~p"/admin/setup"
end end
test "writes a cookie if remember_me was set in previous session", %{conn: conn, user: user} do test "writes a cookie if remember_me was set in previous session", %{conn: conn, user: user} do