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:
parent
34aa8190d6
commit
559798206f
@ -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 ── */
|
||||||
|
|||||||
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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"""
|
||||||
|
|||||||
@ -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"}
|
||||||
|
|||||||
@ -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 · {@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 · {@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 → Account → 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 → 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 —
|
|
||||||
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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
660
lib/berrypod_web/live/admin/setup.ex
Normal file
660
lib/berrypod_web/live/admin/setup.ex
Normal 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 · {@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 · {@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 → Account → 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 → 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 —
|
||||||
|
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
|
||||||
@ -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 %>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
88
test/berrypod_web/live/admin/setup_test.exs
Normal file
88
test/berrypod_web/live/admin/setup_test.exs
Normal 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
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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", %{
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user