replace setup checklist with interactive stepper

3-step vertical stepper with inline forms for Printify and Stripe,
real-time sync progress via PubSub, and celebration state on go-live.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-12 22:55:29 +00:00
parent fdb09128b4
commit 2fb88df853
3 changed files with 701 additions and 65 deletions

View File

@ -66,6 +66,7 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
defp sync_products(conn) do defp sync_products(conn) do
Logger.info("Starting product sync for #{conn.provider_type} (#{conn.id})") Logger.info("Starting product sync for #{conn.provider_type} (#{conn.id})")
Products.update_sync_status(conn, "syncing") Products.update_sync_status(conn, "syncing")
broadcast_sync(conn.id, {:sync_status, "syncing"})
try do try do
do_sync_products(conn) do_sync_products(conn)
@ -73,6 +74,7 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
e -> e ->
Logger.error("Product sync crashed for #{conn.provider_type}: #{Exception.message(e)}") Logger.error("Product sync crashed for #{conn.provider_type}: #{Exception.message(e)}")
Products.update_sync_status(conn, "failed") Products.update_sync_status(conn, "failed")
broadcast_sync(conn.id, {:sync_status, "failed"})
{:error, :sync_crashed} {:error, :sync_crashed}
end end
end end
@ -95,11 +97,14 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
) )
Products.update_sync_status(conn, "completed", DateTime.utc_now()) Products.update_sync_status(conn, "completed", DateTime.utc_now())
product_count = Products.count_products_for_connection(conn.id)
broadcast_sync(conn.id, {:sync_status, "completed", product_count})
:ok :ok
else else
{:error, reason} = error -> {:error, reason} = error ->
Logger.error("Product sync failed for #{conn.provider_type}: #{inspect(reason)}") Logger.error("Product sync failed for #{conn.provider_type}: #{inspect(reason)}")
Products.update_sync_status(conn, "failed") Products.update_sync_status(conn, "failed")
broadcast_sync(conn.id, {:sync_status, "failed"})
error error
end end
end end
@ -146,6 +151,10 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
Products.upsert_product(conn, attrs) Products.upsert_product(conn, attrs)
end end
defp broadcast_sync(conn_id, message) do
Phoenix.PubSub.broadcast(SimpleshopTheme.PubSub, "sync:#{conn_id}", message)
end
defp sync_product_associations(product, product_data) do defp sync_product_associations(product, product_data) do
# Sync images # Sync images
images = images =

View File

@ -1,7 +1,10 @@
defmodule SimpleshopThemeWeb.Admin.Dashboard do defmodule SimpleshopThemeWeb.Admin.Dashboard do
use SimpleshopThemeWeb, :live_view use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.{Cart, Orders, Setup} alias SimpleshopTheme.{Cart, Orders, Products, Settings, Setup}
alias SimpleshopTheme.Products.ProviderConnection
alias SimpleshopTheme.Providers
alias SimpleshopTheme.Stripe.Setup, as: StripeSetup
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -10,15 +13,215 @@ defmodule SimpleshopThemeWeb.Admin.Dashboard do
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")
if conn && connected?(socket) do
Phoenix.PubSub.subscribe(SimpleshopTheme.PubSub, "sync:#{conn.id}")
end
active_step = determine_active_step(status)
{:ok, {:ok,
socket socket
|> assign(:page_title, "Dashboard") |> assign(:page_title, "Dashboard")
|> assign(:setup, status) |> 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(:paid_count, paid_count)
|> assign(:revenue, Orders.total_revenue()) |> assign(:revenue, Orders.total_revenue())
|> assign(:recent_orders, recent_orders)} |> assign(:recent_orders, recent_orders)}
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(SimpleshopTheme.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"""
@ -26,8 +229,24 @@ defmodule SimpleshopThemeWeb.Admin.Dashboard do
Dashboard Dashboard
</.header> </.header>
<%!-- Setup checklist (when not fully set up) --%> <%!-- Celebration state --%>
<.setup_checklist :if={!@setup.site_live} setup={@setup} /> <.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">
@ -55,14 +274,17 @@ defmodule SimpleshopThemeWeb.Admin.Dashboard do
<section class="mt-8"> <section class="mt-8">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">Recent orders</h2> <h2 class="text-lg font-semibold">Recent orders</h2>
<.link navigate={~p"/admin/orders"} class="text-sm text-zinc-500 hover:text-zinc-700"> <.link
navigate={~p"/admin/orders"}
class="text-sm text-base-content/60 hover:text-base-content"
>
View all &rarr; View all &rarr;
</.link> </.link>
</div> </div>
<%= if @recent_orders == [] do %> <%= if @recent_orders == [] do %>
<div class="rounded-lg border border-zinc-200 p-8 text-center text-zinc-500"> <div class="rounded-lg border border-base-200 p-8 text-center text-base-content/60">
<.icon name="hero-inbox" class="size-10 mx-auto mb-3 text-zinc-300" /> <.icon name="hero-inbox" class="size-10 mx-auto mb-3 text-base-content/30" />
<p class="font-medium">No orders yet</p> <p class="font-medium">No orders yet</p>
<p class="text-sm mt-1">Orders will appear here once customers check out.</p> <p class="text-sm mt-1">Orders will appear here once customers check out.</p>
</div> </div>
@ -70,7 +292,7 @@ defmodule SimpleshopThemeWeb.Admin.Dashboard do
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="border-b border-zinc-200 text-left text-zinc-500"> <tr class="border-b border-base-200 text-left text-base-content/60">
<th class="pb-2 font-medium">Order</th> <th class="pb-2 font-medium">Order</th>
<th class="pb-2 font-medium">Date</th> <th class="pb-2 font-medium">Date</th>
<th class="pb-2 font-medium">Customer</th> <th class="pb-2 font-medium">Customer</th>
@ -81,12 +303,12 @@ defmodule SimpleshopThemeWeb.Admin.Dashboard do
<tbody> <tbody>
<tr <tr
:for={order <- @recent_orders} :for={order <- @recent_orders}
class="border-b border-zinc-100 hover:bg-zinc-50 cursor-pointer" class="border-b border-base-200 hover:bg-base-200/50 cursor-pointer"
phx-click={JS.navigate(~p"/admin/orders/#{order}")} phx-click={JS.navigate(~p"/admin/orders/#{order}")}
> >
<td class="py-2.5 font-medium">{order.order_number}</td> <td class="py-2.5 font-medium">{order.order_number}</td>
<td class="py-2.5 text-zinc-600">{format_date(order.inserted_at)}</td> <td class="py-2.5 text-base-content/60">{format_date(order.inserted_at)}</td>
<td class="py-2.5 text-zinc-600">{order.customer_email || ""}</td> <td class="py-2.5 text-base-content/60">{order.customer_email || ""}</td>
<td class="py-2.5 text-right">{Cart.format_price(order.total)}</td> <td class="py-2.5 text-right">{Cart.format_price(order.total)}</td>
<td class="py-2.5"><.fulfilment_pill status={order.fulfilment_status} /></td> <td class="py-2.5"><.fulfilment_pill status={order.fulfilment_status} /></td>
</tr> </tr>
@ -98,58 +320,385 @@ defmodule SimpleshopThemeWeb.Admin.Dashboard do
""" """
end end
# -- Function components -- # ==========================================================================
# Setup stepper components
# ==========================================================================
attr :setup, :map, required: true 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_checklist(assigns) do defp setup_stepper(assigns) do
steps = [
%{done: assigns.setup.admin_created, label: "Create admin account", href: nil},
%{
done: assigns.setup.printify_connected,
label: "Connect to Printify",
href: ~p"/admin/providers/new"
},
%{done: assigns.setup.products_synced, label: "Sync products", href: nil},
%{done: assigns.setup.stripe_connected, label: "Connect Stripe", href: ~p"/admin/settings"},
%{done: assigns.setup.site_live, label: "Go live", href: ~p"/admin/settings"}
]
done_count = Enum.count(steps, & &1.done)
assigns = assign(assigns, steps: steps, done_count: done_count, total: length(steps))
~H""" ~H"""
<div class="mt-6 rounded-lg border border-zinc-200 p-5"> <div class="mt-6">
<div class="flex items-center justify-between mb-3"> <ol class="relative" aria-label="Setup steps">
<h2 class="font-semibold">Setup progress</h2> <%!-- Step 1: Printify --%>
<span class="text-sm text-zinc-500">{@done_count}/{@total}</span> <.setup_step
</div> step={:printify}
<div class="w-full bg-zinc-100 rounded-full h-2 mb-4"> number={1}
<div title="Connect to Printify"
class="bg-green-500 h-2 rounded-full transition-all" active_step={@active_step}
style={"width: #{@done_count / @total * 100}%"} done={@setup.printify_connected and @setup.products_synced}
last={false}
next_done={@setup.stripe_connected}
> >
</div> <:summary :if={@setup.printify_connected and @setup.products_synced}>
</div> Connected &middot; {@setup.product_count} products synced
<ul class="space-y-2"> </:summary>
<li :for={step <- @steps} class="flex items-center gap-2 text-sm"> <:content>
<%= if step.done do %> <.printify_step_content
<.icon name="hero-check-circle" class="size-5 text-green-500 shrink-0" /> setup={@setup}
<span class="text-zinc-500 line-through">{step.label}</span> printify_conn={@printify_conn}
<% else %> printify_form={@printify_form}
<.icon name="hero-circle-stack" class="size-5 text-zinc-300 shrink-0" /> printify_testing={@printify_testing}
<%= if step.href do %> printify_test_result={@printify_test_result}
<.link navigate={step.href} class="text-zinc-700 hover:underline">{step.label}</.link> printify_saving={@printify_saving}
<% else %> sync_status={@sync_status}
<span class="text-zinc-700">{step.label}</span> />
<% end %> </:content>
<% end %> </.setup_step>
</li>
</ul> <%!-- 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> </div>
""" """
end 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
attr :value, :any, required: true attr :value, :any, required: true
attr :icon, :string, required: true attr :icon, :string, required: true
@ -159,15 +708,15 @@ defmodule SimpleshopThemeWeb.Admin.Dashboard do
~H""" ~H"""
<.link <.link
navigate={@href} navigate={@href}
class="rounded-lg border border-zinc-200 p-4 hover:border-zinc-300 transition-colors" class="rounded-lg border border-base-200 p-4 hover:border-base-300 transition-colors"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="rounded-lg bg-zinc-100 p-2"> <div class="rounded-lg bg-base-200 p-2">
<.icon name={@icon} class="size-5 text-zinc-600" /> <.icon name={@icon} class="size-5 text-base-content/60" />
</div> </div>
<div> <div>
<p class="text-2xl font-bold">{@value}</p> <p class="text-2xl font-bold">{@value}</p>
<p class="text-sm text-zinc-500">{@label}</p> <p class="text-sm text-base-content/60">{@label}</p>
</div> </div>
</div> </div>
</.link> </.link>
@ -177,13 +726,13 @@ defmodule SimpleshopThemeWeb.Admin.Dashboard do
defp fulfilment_pill(assigns) do defp fulfilment_pill(assigns) do
{color, label} = {color, label} =
case assigns.status do case assigns.status do
"unfulfilled" -> {"bg-zinc-100 text-zinc-600", "unfulfilled"} "unfulfilled" -> {"bg-base-200 text-base-content/60", "unfulfilled"}
"submitted" -> {"bg-blue-50 text-blue-700", "submitted"} "submitted" -> {"bg-blue-50 text-blue-700", "submitted"}
"processing" -> {"bg-amber-50 text-amber-700", "processing"} "processing" -> {"bg-amber-50 text-amber-700", "processing"}
"shipped" -> {"bg-purple-50 text-purple-700", "shipped"} "shipped" -> {"bg-purple-50 text-purple-700", "shipped"}
"delivered" -> {"bg-green-50 text-green-700", "delivered"} "delivered" -> {"bg-green-50 text-green-700", "delivered"}
"failed" -> {"bg-red-50 text-red-700", "failed"} "failed" -> {"bg-red-50 text-red-700", "failed"}
_ -> {"bg-zinc-100 text-zinc-600", assigns.status || ""} _ -> {"bg-base-200 text-base-content/60", assigns.status || ""}
end end
assigns = assign(assigns, color: color, label: label) assigns = assign(assigns, color: color, label: label)
@ -195,6 +744,10 @@ defmodule SimpleshopThemeWeb.Admin.Dashboard do
""" """
end end
# ==========================================================================
# Helpers
# ==========================================================================
defp format_revenue(amount_pence) when is_integer(amount_pence) do defp format_revenue(amount_pence) when is_integer(amount_pence) do
Cart.format_price(amount_pence) Cart.format_price(amount_pence)
end end
@ -204,4 +757,31 @@ defmodule SimpleshopThemeWeb.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 SimpleshopTheme.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

@ -4,6 +4,7 @@ defmodule SimpleshopThemeWeb.Admin.DashboardTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import SimpleshopTheme.AccountsFixtures import SimpleshopTheme.AccountsFixtures
import SimpleshopTheme.OrdersFixtures import SimpleshopTheme.OrdersFixtures
import SimpleshopTheme.ProductsFixtures
setup do setup do
user = user_fixture() user = user_fixture()
@ -18,26 +19,72 @@ defmodule SimpleshopThemeWeb.Admin.DashboardTest do
end end
end end
describe "setup checklist" do describe "setup stepper" 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 setup progress when shop is offline", %{conn: conn} do test "shows stepper with printify form when nothing connected", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin") {:ok, _view, html} = live(conn, ~p"/admin")
assert html =~ "Setup progress" assert html =~ "Setup steps"
assert html =~ "Create admin account"
assert html =~ "Connect to Printify" assert html =~ "Connect to Printify"
assert html =~ "Printify API token"
assert html =~ "Connect Stripe" assert html =~ "Connect Stripe"
assert html =~ "Go live" assert html =~ "Go live"
end end
test "hides setup checklist when shop is live", %{conn: conn} do 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, _} = SimpleshopTheme.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, _} = SimpleshopTheme.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, _} = SimpleshopTheme.Settings.set_site_live(true) {:ok, _} = SimpleshopTheme.Settings.set_site_live(true)
{:ok, _view, html} = live(conn, ~p"/admin") {:ok, _view, html} = live(conn, ~p"/admin")
refute html =~ "Setup progress" 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