add setup onboarding page, dashboard launch checklist, provider registry

- new /setup page with three-section onboarding (account, provider, payments)
- dashboard launch checklist with progress bar, go-live, dismiss
- provider registry on Provider module (single source of truth for metadata)
- payments registry for Stripe
- setup context made provider-agnostic (provider_connected, theme_customised, etc.)
- admin provider pages now fully registry-driven (no hardcoded provider names)
- auth flow: fresh installs redirect to /setup, signed_in_path respects setup state
- removed old /admin/setup wizard
- 840 tests, 0 failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-20 00:34:06 +00:00
parent 989c5cd4df
commit c2caeed64d
33 changed files with 1927 additions and 1053 deletions

View File

@@ -50,6 +50,16 @@ defmodule Berrypod.Orders do
|> Map.new()
end
@doc """
Returns true if at least one paid order exists.
"""
def has_paid_orders? do
Order
|> where(payment_status: "paid")
|> limit(1)
|> Repo.exists?()
end
@doc """
Returns total revenue (in minor units) from paid orders.
"""

View File

@@ -0,0 +1,28 @@
defmodule Berrypod.Payments.Registry do
@moduledoc """
Payment provider metadata registry.
Lightweight — just Stripe for now. No behaviour abstraction until
a second payment provider justifies one.
"""
@providers [
%{
type: "stripe",
name: "Stripe",
tagline: "Accept cards, Apple Pay, Google Pay and more",
setup_hint: "Find your secret key under Developers → API keys",
setup_url: "https://dashboard.stripe.com/apikeys",
status: :available
}
]
@doc "Returns all payment providers."
def all, do: @providers
@doc "Returns only providers with `:available` status."
def available, do: Enum.filter(@providers, &(&1.status == :available))
@doc "Returns a payment provider by type string, or nil."
def get(type), do: Enum.find(@providers, &(&1.type == type))
end

View File

@@ -42,6 +42,19 @@ defmodule Berrypod.Products do
Repo.get_by(ProviderConnection, provider_type: provider_type)
end
@doc """
Returns the first provider connection with an API key, or nil.
Provider-agnostic — doesn't care which type. Ordered by creation date.
"""
def get_first_provider_connection do
ProviderConnection
|> where([c], not is_nil(c.api_key_encrypted))
|> order_by(:inserted_at)
|> limit(1)
|> Repo.one()
end
@doc """
Creates a provider connection.
"""

View File

@@ -1,8 +1,8 @@
defmodule Berrypod.Providers.Provider do
@moduledoc """
Behaviour for POD provider integrations.
Behaviour and registry for POD provider integrations.
Each provider (Printify, Gelato, Prodigi, etc.) implements this behaviour
Each provider (Printify, Printful, etc.) implements this behaviour
to provide a consistent interface for:
- Testing connections
@@ -10,6 +10,10 @@ defmodule Berrypod.Providers.Provider do
- Submitting orders
- Tracking order status
The `@providers` list is the single source of truth for available providers.
Both the module dispatch (`for_type/1`) and UI metadata (`all/0`, `get/1`)
are derived from it.
## Data Normalization
Providers return normalized data structures:
@@ -23,47 +27,82 @@ defmodule Berrypod.Providers.Provider do
alias Berrypod.Products.ProviderConnection
@doc """
Returns the provider type identifier (e.g., "printify", "gelato").
"""
# Single source of truth for all providers — module dispatch and UI metadata.
# Add new providers here. Set module: nil for coming-soon entries.
@providers [
%{
type: "printify",
module: Berrypod.Providers.Printify,
name: "Printify",
tagline: "Largest catalog — 800+ products from 90+ print providers",
features: ["Biggest product selection", "Multi-provider routing", "Competitive pricing"],
setup_hint: "Get your API token from Printify → Account → Connections",
setup_url: "https://printify.com/app/account/connections",
login_url: "https://printify.com/app/auth/login",
signup_url: "https://printify.com/app/auth/register",
setup_steps: [
~s[Click <strong>Account</strong> (top right)],
~s[Select <strong>Connections</strong> from the dropdown],
~s[Find <strong>API tokens</strong> and click <strong>Generate</strong>],
~s[Enter a name (e.g. "My Shop"), keep <strong>all scopes</strong> selected, and click <strong>Generate token</strong>],
~s[Click <strong>Copy to clipboard</strong> and paste it below]
],
status: :available
},
%{
type: "printful",
module: Berrypod.Providers.Printful,
name: "Printful",
tagline: "Premium quality with in-house fulfilment",
features: ["In-house production", "Warehousing & branding", "Mockup generator"],
setup_hint: "Get your API token from Printful → Settings → API",
setup_url: "https://www.printful.com/dashboard/developer/api",
login_url: "https://www.printful.com/auth/login",
signup_url: "https://www.printful.com/auth/signup",
setup_steps: [
~s[Go to <strong>Settings</strong> &rarr; <strong>API access</strong>],
~s[Click <strong>Create API key</strong>],
~s[Give it a name and select <strong>all scopes</strong>],
~s[Copy the token and paste it below]
],
status: :available
},
%{
type: "gelato",
module: nil,
name: "Gelato",
tagline: "Local production in 30+ countries",
status: :coming_soon
},
%{
type: "prodigi",
module: nil,
name: "Prodigi",
tagline: "Fine art and photo products",
status: :coming_soon
}
]
# Build a compile-time lookup map for for_type/1
@type_to_module Map.new(@providers, fn p -> {p.type, p[:module]} end)
# ── Callbacks ──
@callback provider_type() :: String.t()
@doc """
Tests the connection to the provider.
Returns `{:ok, info}` with provider-specific info (e.g., shop name)
or `{:error, reason}` if the connection fails.
"""
@callback test_connection(ProviderConnection.t()) :: {:ok, map()} | {:error, term()}
@doc """
Fetches all products from the provider.
Returns a list of normalized product maps.
"""
@callback fetch_products(ProviderConnection.t()) :: {:ok, [map()]} | {:error, term()}
@doc """
Submits an order to the provider for fulfillment.
Returns `{:ok, %{provider_order_id: String.t()}}` on success.
"""
@callback submit_order(ProviderConnection.t(), order :: map()) ::
{:ok, %{provider_order_id: String.t()}} | {:error, term()}
@doc """
Gets the current status of an order from the provider.
"""
@callback get_order_status(ProviderConnection.t(), provider_order_id :: String.t()) ::
{:ok, map()} | {:error, term()}
@doc """
Fetches shipping rates from the provider for the given products.
Takes the connection and the already-fetched product list (from fetch_products).
Returns normalized rate maps with keys: blueprint_id, print_provider_id,
country_code, first_item_cost, additional_item_cost, currency, handling_time_days.
Optional — providers that don't support shipping rate lookup can skip this.
The sync worker checks `function_exported?/3` before calling.
"""
@@ -72,11 +111,24 @@ defmodule Berrypod.Providers.Provider do
@optional_callbacks [fetch_shipping_rates: 2]
# ── Registry ──
@doc "Returns all providers (available and coming soon)."
def all, do: @providers
@doc "Returns only providers with `:available` status."
def available, do: Enum.filter(@providers, &(&1.status == :available))
@doc "Returns a provider metadata map by type string, or nil."
def get(type), do: Enum.find(@providers, &(&1.type == type))
# ── Module dispatch ──
@doc """
Returns the provider module for a given provider type.
Checks `:provider_modules` application config first, allowing test
overrides via Mox. Falls back to hardcoded dispatch.
overrides via Mox. Falls back to the `@providers` list.
"""
def for_type(type) do
case Application.get_env(:berrypod, :provider_modules, %{}) do
@@ -91,11 +143,13 @@ defmodule Berrypod.Providers.Provider do
end
end
defp default_for_type("printify"), do: {:ok, Berrypod.Providers.Printify}
defp default_for_type("gelato"), do: {:error, :not_implemented}
defp default_for_type("prodigi"), do: {:error, :not_implemented}
defp default_for_type("printful"), do: {:ok, Berrypod.Providers.Printful}
defp default_for_type(type), do: {:error, {:unknown_provider, type}}
defp default_for_type(type) do
case Map.fetch(@type_to_module, type) do
{:ok, nil} -> {:error, :not_implemented}
{:ok, module} -> {:ok, module}
:error -> {:error, {:unknown_provider, type}}
end
end
@doc """
Returns the provider module for a provider connection.

View File

@@ -84,6 +84,7 @@ defmodule Berrypod.Settings do
settings = Ecto.Changeset.apply_changes(changeset)
json = Jason.encode!(settings)
put_setting("theme_settings", json, "json")
put_setting("theme_customised", true, "boolean")
# Invalidate and rewarm CSS cache
alias Berrypod.Theme.{CSSCache, CSSGenerator}

View File

@@ -1,33 +1,58 @@
defmodule Berrypod.Setup do
@moduledoc """
Aggregates setup status checks for the admin setup flow.
Aggregates setup status checks for the setup flow and launch checklist.
"""
alias Berrypod.{Accounts, Products, Settings}
alias Berrypod.{Accounts, Orders, Products, Settings}
@doc """
Returns a map describing the current setup status.
Used by the admin setup checklist and ThemeHook gate to determine
what's been completed and whether the shop can go live.
Used by the setup page, dashboard launch checklist, and ThemeHook gate.
## Setup phase (connections)
* `admin_created` — at least one user exists
* `provider_connected` — a provider connection with an API key exists
* `provider_type` — the connected provider's type (e.g. "printify"), or nil
* `stripe_connected` — Stripe API key is stored
* `setup_complete` — all three connections made
## Launch checklist phase
* `products_synced` / `product_count` — products imported
* `theme_customised` — theme settings have been saved at least once
* `has_orders` — at least one paid order exists
* `site_live` — shop is open to the public
* `can_go_live` — minimum requirements met to go live
* `checklist_dismissed` — admin has dismissed the launch checklist
"""
def setup_status do
conn = Products.get_provider_connection_by_type("printify")
product_count = Products.count_products_for_connection(conn && conn.id)
conn = Products.get_first_provider_connection()
product_count = Products.count_products()
printify_connected = conn != nil and conn.api_key_encrypted != nil
provider_connected = conn != nil
products_synced = product_count > 0
stripe_connected = Settings.has_secret?("stripe_api_key")
admin_created = Accounts.has_admin?()
site_live = Settings.site_live?()
%{
admin_created: Accounts.has_admin?(),
printify_connected: printify_connected,
# Setup phase
admin_created: admin_created,
provider_connected: provider_connected,
provider_type: conn && conn.provider_type,
stripe_connected: stripe_connected,
setup_complete: admin_created and provider_connected and stripe_connected,
# Launch checklist
products_synced: products_synced,
product_count: product_count,
stripe_connected: stripe_connected,
theme_customised: Settings.get_setting("theme_customised", false) == true,
has_orders: Orders.has_paid_orders?(),
site_live: site_live,
can_go_live: printify_connected and products_synced and stripe_connected
can_go_live: provider_connected and products_synced and stripe_connected,
checklist_dismissed: Settings.get_setting("checklist_dismissed", false) == true
}
end
end

View File

@@ -43,14 +43,6 @@
<%!-- nav links --%>
<nav class="flex-1 p-2" aria-label="Admin navigation">
<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>
<.link
navigate={~p"/admin"}

View File

@@ -3,25 +3,57 @@ defmodule BerrypodWeb.Admin.Dashboard do
alias Berrypod.{Cart, Orders, Products, Settings}
@checklist_items [
%{key: :products_synced, label: "Sync your products", href: "/admin/providers"},
%{key: :theme_customised, label: "Customise your theme", href: "/admin/theme"},
%{key: :has_orders, label: "Place a test order", href: "/"},
%{key: :site_live, label: "Go live", href: nil}
]
@impl true
def mount(_params, _session, socket) do
if Settings.site_live?() do
status_counts = Orders.count_orders_by_status()
paid_count = Map.get(status_counts, "paid", 0)
recent_orders = Orders.list_orders(status: "paid") |> Enum.take(5)
setup = Berrypod.Setup.setup_status()
status_counts = Orders.count_orders_by_status()
paid_count = Map.get(status_counts, "paid", 0)
recent_orders = Orders.list_orders(status: "paid") |> Enum.take(5)
{:ok,
socket
|> assign(:page_title, "Dashboard")
|> assign(:paid_count, paid_count)
|> assign(:revenue, Orders.total_revenue())
|> assign(:product_count, Products.count_products())
|> assign(:recent_orders, recent_orders)}
else
{:ok, push_navigate(socket, to: ~p"/admin/setup")}
end
{:ok,
socket
|> assign(:page_title, "Dashboard")
|> assign(:setup, setup)
|> assign(:show_checklist, show_checklist?(setup))
|> assign(:just_went_live, false)
|> assign(:paid_count, paid_count)
|> assign(:revenue, Orders.total_revenue())
|> assign(:product_count, Products.count_products())
|> assign(:recent_orders, recent_orders)}
end
# ── Events ──
@impl true
def handle_event("go_live", _params, socket) do
{:ok, _} = Settings.set_site_live(true)
setup = %{socket.assigns.setup | site_live: true}
{:noreply,
socket
|> assign(:setup, setup)
|> assign(:just_went_live, true)}
end
def handle_event("dismiss_checklist", _params, socket) do
{:ok, _} = Settings.put_setting("checklist_dismissed", true, "boolean")
setup = %{socket.assigns.setup | checklist_dismissed: true}
{:noreply,
socket
|> assign(:setup, setup)
|> assign(:show_checklist, false)}
end
# ── Render ──
@impl true
def render(assigns) do
~H"""
@@ -29,8 +61,26 @@ defmodule BerrypodWeb.Admin.Dashboard do
Dashboard
</.header>
<%!-- Celebration after go-live --%>
<div :if={@just_went_live} class="setup-complete" style="margin-top: 1.5rem;">
<.icon name="hero-check-badge" class="setup-complete-icon" />
<h2>Your shop is live!</h2>
<p>Customers can now browse and buy from your shop.</p>
<div style="display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap;">
<.link href={~p"/"} class="admin-btn admin-btn-primary">
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> View your shop
</.link>
<.link navigate={~p"/admin/theme"} class="admin-btn admin-btn-secondary">
<.icon name="hero-paint-brush-mini" class="size-4" /> Customise theme
</.link>
</div>
</div>
<%!-- Launch checklist --%>
<.launch_checklist :if={@show_checklist and !@just_went_live} setup={@setup} />
<%!-- Stats --%>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-6">
<div class="admin-stats-grid">
<.stat_card
label="Orders"
value={@paid_count}
@@ -52,46 +102,50 @@ defmodule BerrypodWeb.Admin.Dashboard do
</div>
<%!-- Recent orders --%>
<section class="mt-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">Recent orders</h2>
<section style="margin-top: 2rem;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
<h2 style="font-size: 1.125rem; font-weight: 600;">Recent orders</h2>
<.link
navigate={~p"/admin/orders"}
class="text-sm text-base-content/60 hover:text-base-content"
style="font-size: 0.875rem; color: var(--color-base-content-60, rgba(0 0 0 / 0.6));"
>
View all &rarr;
</.link>
</div>
<%= if @recent_orders == [] do %>
<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-base-content/30" />
<p class="font-medium">No orders yet</p>
<p class="text-sm mt-1">Orders will appear here once customers check out.</p>
<div style="border: 1px solid var(--color-base-200, #e5e5e5); border-radius: 0.5rem; padding: 2rem; text-align: center; color: var(--color-base-content-60, rgba(0 0 0 / 0.6));">
<div style="margin: 0 auto 0.75rem; width: 2.5rem; opacity: 0.3;">
<.icon name="hero-inbox" class="size-10" />
</div>
<p style="font-weight: 500;">No orders yet</p>
<p style="font-size: 0.875rem; margin-top: 0.25rem;">
Orders will appear here once customers check out.
</p>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<div style="overflow-x: auto;">
<table class="admin-table">
<thead>
<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">Date</th>
<th class="pb-2 font-medium">Customer</th>
<th class="pb-2 font-medium text-right">Total</th>
<th class="pb-2 font-medium">Fulfilment</th>
<tr>
<th>Order</th>
<th>Date</th>
<th>Customer</th>
<th style="text-align: right;">Total</th>
<th>Fulfilment</th>
</tr>
</thead>
<tbody>
<tr
:for={order <- @recent_orders}
class="border-b border-base-200 hover:bg-base-200/50 cursor-pointer"
phx-click={JS.navigate(~p"/admin/orders/#{order}")}
style="cursor: pointer;"
>
<td class="py-2.5 font-medium">{order.order_number}</td>
<td class="py-2.5 text-base-content/60">{format_date(order.inserted_at)}</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"><.fulfilment_pill status={order.fulfilment_status} /></td>
<td style="font-weight: 500;">{order.order_number}</td>
<td>{format_date(order.inserted_at)}</td>
<td>{order.customer_email || "—"}</td>
<td style="text-align: right;">{Cart.format_price(order.total)}</td>
<td><.fulfilment_pill status={order.fulfilment_status} /></td>
</tr>
</tbody>
</table>
@@ -101,6 +155,91 @@ defmodule BerrypodWeb.Admin.Dashboard do
"""
end
# ==========================================================================
# Launch checklist component
# ==========================================================================
attr :setup, :map, required: true
defp launch_checklist(assigns) do
items =
Enum.map(@checklist_items, fn item ->
Map.put(item, :done, Map.get(assigns.setup, item.key, false))
end)
done_count = Enum.count(items, & &1.done)
total = length(items)
progress_pct = round(done_count / total * 100)
can_go_live =
assigns.setup.provider_connected and assigns.setup.products_synced and
assigns.setup.stripe_connected
assigns =
assigns
|> assign(:items, items)
|> assign(:done_count, done_count)
|> assign(:total, total)
|> assign(:progress_pct, progress_pct)
|> assign(:can_go_live, can_go_live)
~H"""
<div class="admin-checklist" style="margin-top: 1.5rem;">
<div class="admin-checklist-header">
<h2 class="admin-checklist-title">Launch checklist</h2>
<div class="admin-checklist-progress">
<span>{@done_count} of {@total}</span>
<div class="admin-checklist-bar">
<div class="admin-checklist-bar-fill" style={"width: #{@progress_pct}%"} />
</div>
</div>
</div>
<ul class="admin-checklist-items">
<li :for={item <- @items} class="admin-checklist-item">
<span class={["admin-checklist-check", item.done && "admin-checklist-check-done"]}>
<.icon :if={item.done} name="hero-check-mini" class="size-3" />
</span>
<span class={["admin-checklist-label", item.done && "admin-checklist-label-done"]}>
{item.label}
</span>
<span class="admin-checklist-action">
<%= if item.key == :site_live do %>
<button
phx-click="go_live"
disabled={!@can_go_live}
class="admin-btn admin-btn-primary admin-btn-sm"
>
<.icon name="hero-rocket-launch-mini" class="size-4" /> Go live
</button>
<% else %>
<.link
:if={!item.done}
navigate={item.href}
class="admin-btn admin-btn-secondary admin-btn-sm"
>
{if item.done, do: "View", else: "Start"} &rarr;
</.link>
<% end %>
</span>
</li>
</ul>
<div class="admin-checklist-footer">
<button
type="button"
phx-click="dismiss_checklist"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
Dismiss
</button>
</div>
</div>
"""
end
# ==========================================================================
# Components
# ==========================================================================
@@ -114,15 +253,18 @@ defmodule BerrypodWeb.Admin.Dashboard do
~H"""
<.link
navigate={@href}
class="rounded-lg border border-base-200 p-4 hover:border-base-300 transition-colors"
class="admin-card"
style="display: block; text-decoration: none;"
>
<div class="flex items-center gap-3">
<div class="rounded-lg bg-base-200 p-2">
<.icon name={@icon} class="size-5 text-base-content/60" />
<div style="display: flex; align-items: center; gap: 0.75rem; padding: 1rem;">
<div style="background: var(--color-base-200, #e5e5e5); border-radius: 0.5rem; padding: 0.5rem;">
<.icon name={@icon} class="size-5" />
</div>
<div>
<p class="text-2xl font-bold">{@value}</p>
<p class="text-sm text-base-content/60">{@label}</p>
<p style="font-size: 1.5rem; font-weight: 700;">{@value}</p>
<p style="font-size: 0.875rem; color: var(--color-base-content-60, rgba(0 0 0 / 0.6));">
{@label}
</p>
</div>
</div>
</.link>
@@ -132,19 +274,19 @@ defmodule BerrypodWeb.Admin.Dashboard do
defp fulfilment_pill(assigns) do
{color, label} =
case assigns.status do
"unfulfilled" -> {"bg-base-200 text-base-content/60", "unfulfilled"}
"submitted" -> {"bg-blue-50 text-blue-700", "submitted"}
"processing" -> {"bg-amber-50 text-amber-700", "processing"}
"shipped" -> {"bg-purple-50 text-purple-700", "shipped"}
"delivered" -> {"bg-green-50 text-green-700", "delivered"}
"failed" -> {"bg-red-50 text-red-700", "failed"}
_ -> {"bg-base-200 text-base-content/60", assigns.status || ""}
"unfulfilled" -> {"var(--color-base-200, #e5e5e5)", "unfulfilled"}
"submitted" -> {"#dbeafe", "submitted"}
"processing" -> {"#fef3c7", "processing"}
"shipped" -> {"#f3e8ff", "shipped"}
"delivered" -> {"#dcfce7", "delivered"}
"failed" -> {"#fee2e2", "failed"}
_ -> {"var(--color-base-200, #e5e5e5)", assigns.status || ""}
end
assigns = assign(assigns, color: color, label: label)
~H"""
<span class={["inline-flex rounded-full px-2 py-0.5 text-xs font-medium", @color]}>
<span style={"display: inline-flex; border-radius: 9999px; padding: 0.125rem 0.5rem; font-size: 0.75rem; font-weight: 500; background: #{@color};"}>
{@label}
</span>
"""
@@ -154,6 +296,10 @@ defmodule BerrypodWeb.Admin.Dashboard do
# Helpers
# ==========================================================================
defp show_checklist?(setup) do
not setup.site_live and not setup.checklist_dismissed
end
defp format_revenue(amount_pence) when is_integer(amount_pence) do
Cart.format_price(amount_pence)
end

View File

@@ -4,8 +4,9 @@ defmodule BerrypodWeb.Admin.Providers.Form do
alias Berrypod.Products
alias Berrypod.Products.ProviderConnection
alias Berrypod.Providers
alias Berrypod.Providers.Provider
@supported_types ~w(printify printful)
@supported_types Enum.map(Provider.available(), & &1.type)
@impl true
def mount(params, _session, socket) do
@@ -14,10 +15,12 @@ defmodule BerrypodWeb.Admin.Providers.Form do
defp apply_action(socket, :new, params) do
provider_type = validated_type(params["type"])
provider = Provider.get(provider_type)
socket
|> assign(:page_title, "Connect to #{provider_label(provider_type)}")
|> assign(:page_title, "Connect to #{provider.name}")
|> assign(:provider_type, provider_type)
|> assign(:provider, provider)
|> assign(:connection, %ProviderConnection{provider_type: provider_type})
|> assign(:form, to_form(ProviderConnection.changeset(%ProviderConnection{}, %{})))
|> assign(:testing, false)
@@ -27,10 +30,12 @@ defmodule BerrypodWeb.Admin.Providers.Form do
defp apply_action(socket, :edit, %{"id" => id}) do
connection = Products.get_provider_connection!(id)
provider = Provider.get(connection.provider_type)
socket
|> assign(:page_title, "#{provider_label(connection.provider_type)} settings")
|> assign(:page_title, "#{provider.name} settings")
|> assign(:provider_type, connection.provider_type)
|> assign(:provider, provider)
|> assign(:connection, connection)
|> assign(:form, to_form(ProviderConnection.changeset(connection, %{})))
|> assign(:testing, false)
@@ -89,7 +94,7 @@ defmodule BerrypodWeb.Admin.Providers.Form do
{:ok, _connection} ->
{:noreply,
socket
|> put_flash(:info, "Connected to #{provider_label(provider_type)}!")
|> put_flash(:info, "Connected to #{socket.assigns.provider.name}!")
|> push_navigate(to: ~p"/admin/settings")}
{:error, %Ecto.Changeset{} = changeset} ->
@@ -132,7 +137,8 @@ defmodule BerrypodWeb.Admin.Providers.Form do
end
defp maybe_add_name(params, type, _result) do
Map.put_new(params, "name", provider_label(type))
provider = Provider.get(type)
Map.put_new(params, "name", provider && provider.name || type)
end
defp encrypt_api_key(api_key) do
@@ -147,9 +153,6 @@ defmodule BerrypodWeb.Admin.Providers.Form do
# Shared helpers used by the template
defp provider_label("printful"), do: "Printful"
defp provider_label(_), do: "Printify"
defp connection_name({:ok, %{shop_name: name}}), do: name
defp connection_name({:ok, %{store_name: name}}), do: name
defp connection_name(_), do: nil

View File

@@ -1,75 +1,32 @@
<.header>
{if @live_action == :new,
do: "Connect to #{provider_label(@provider_type)}",
else: "#{provider_label(@provider_type)} settings"}
do: "Connect to #{@provider.name}",
else: "#{@provider.name} settings"}
</.header>
<div class="max-w-xl mt-6">
<%= if @live_action == :new do %>
<div class="prose prose-sm mb-6">
<p>
{provider_label(@provider_type)} is a print-on-demand service that prints and ships products for you.
{@provider.name} is a print-on-demand service that prints and ships products for you.
Connect your account to automatically import your products into your shop.
</p>
</div>
<%= if @provider_type == "printify" do %>
<div class="rounded-lg bg-base-200 p-4 mb-6 text-sm">
<p class="font-medium mb-2">Get your API key from Printify:</p>
<ol class="list-decimal list-inside space-y-1 text-base-content/80">
<li>
<a
href="https://printify.com/app/auth/login"
target="_blank"
rel="noopener"
class="admin-link"
>
Log in to Printify
</a>
(or <a
href="https://printify.com/app/auth/register"
target="_blank"
rel="noopener"
class="admin-link"
>create a free account</a>)
</li>
<li>Click <strong>Account</strong> (top right)</li>
<li>Select <strong>Connections</strong> from the dropdown</li>
<li>Find <strong>API tokens</strong> and click <strong>Generate</strong></li>
<li>
Enter a name (e.g. "My Shop"), keep <strong>all scopes</strong>
selected, and click <strong>Generate token</strong>
</li>
<li>Click <strong>Copy to clipboard</strong> and paste it below</li>
</ol>
</div>
<% else %>
<div class="rounded-lg bg-base-200 p-4 mb-6 text-sm">
<p class="font-medium mb-2">Get your API key from Printful:</p>
<ol class="list-decimal list-inside space-y-1 text-base-content/80">
<li>
<a
href="https://www.printful.com/auth/login"
target="_blank"
rel="noopener"
class="admin-link"
>
Log in to Printful
</a>
(or <a
href="https://www.printful.com/auth/signup"
target="_blank"
rel="noopener"
class="admin-link"
>create a free account</a>)
</li>
<li>Go to <strong>Settings</strong> &rarr; <strong>API access</strong></li>
<li>Click <strong>Create API key</strong></li>
<li>Give it a name and select <strong>all scopes</strong></li>
<li>Copy the token and paste it below</li>
</ol>
</div>
<% end %>
<div class="rounded-lg bg-base-200 p-4 mb-6 text-sm">
<p class="font-medium mb-2">Get your API key from {@provider.name}:</p>
<ol class="list-decimal list-inside space-y-1 text-base-content/80">
<li>
<a href={@provider.login_url} target="_blank" rel="noopener" class="admin-link">
Log in to {@provider.name}
</a>
(or <a href={@provider.signup_url} target="_blank" rel="noopener" class="admin-link">create a free account</a>)
</li>
<li :for={step <- @provider.setup_steps}>
{raw(step)}
</li>
</ol>
</div>
<% end %>
<.form for={@form} id="provider-form" phx-change="validate" phx-submit="save">
@@ -78,7 +35,7 @@
<.input
field={@form[:api_key]}
type="password"
label={"#{provider_label(@provider_type)} API key"}
label={"#{@provider.name} API key"}
placeholder={
if @live_action == :edit,
do: "Leave blank to keep current key",
@@ -106,7 +63,7 @@
<% {:ok, _info} -> %>
<span class="text-success flex items-center gap-1">
<.icon name="hero-check-circle" class="size-4" />
Connected to {connection_name(@test_result) || provider_label(@provider_type)}
Connected to {connection_name(@test_result) || @provider.name}
</span>
<% {:error, reason} -> %>
<span class="text-error flex items-center gap-1">
@@ -124,7 +81,7 @@
<div class="flex gap-2 mt-6">
<.button type="submit" disabled={@testing}>
{if @live_action == :new,
do: "Connect to #{provider_label(@provider_type)}",
do: "Connect to #{@provider.name}",
else: "Save changes"}
</.button>
<.link navigate={~p"/admin/providers"} class="admin-btn admin-btn-ghost">

View File

@@ -3,6 +3,7 @@ defmodule BerrypodWeb.Admin.Providers.Index do
alias Berrypod.Products
alias Berrypod.Products.ProviderConnection
alias Berrypod.Providers.Provider
@impl true
def mount(_params, _session, socket) do
@@ -11,6 +12,7 @@ defmodule BerrypodWeb.Admin.Providers.Index do
{:ok,
socket
|> assign(:page_title, "Provider connections")
|> assign(:available_providers, Provider.available())
|> stream(:connections, connections)}
end
@@ -85,6 +87,13 @@ defmodule BerrypodWeb.Admin.Providers.Index do
"""
end
defp provider_name(type) do
case Provider.get(type) do
%{name: name} -> name
nil -> String.capitalize(type)
end
end
defp format_relative_time(datetime) do
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)

View File

@@ -6,11 +6,8 @@
<.icon name="hero-plus" class="size-4 mr-1" /> Connect provider
</div>
<ul tabindex="0" class="admin-dropdown-content">
<li>
<.link navigate={~p"/admin/providers/new?type=printify"}>Printify</.link>
</li>
<li>
<.link navigate={~p"/admin/providers/new?type=printful"}>Printful</.link>
<li :for={provider <- @available_providers}>
<.link navigate={~p"/admin/providers/new?type=#{provider.type}"}>{provider.name}</.link>
</li>
</ul>
</div>
@@ -22,15 +19,14 @@
<.icon name="hero-cube" class="size-16 mx-auto mb-4 text-base-content/30" />
<h2 class="text-xl font-medium">Connect a print-on-demand provider</h2>
<p class="mt-2 text-base-content/60 max-w-md mx-auto">
Connect your Printify or Printful account to import products
and start selling.
Connect your account to import products and start selling.
</p>
<div class="flex justify-center gap-3 mt-6">
<.button navigate={~p"/admin/providers/new?type=printify"}>
Connect Printify
</.button>
<.button navigate={~p"/admin/providers/new?type=printful"} variant="outline">
Connect Printful
<.button
:for={provider <- @available_providers}
navigate={~p"/admin/providers/new?type=#{provider.type}"}
>
Connect {provider.name}
</.button>
</div>
</div>
@@ -46,7 +42,7 @@
<div class="flex items-center gap-2">
<.status_indicator status={connection.sync_status} enabled={connection.enabled} />
<h3 class="font-semibold text-lg">
{String.capitalize(connection.provider_type)}
{provider_name(connection.provider_type)}
</h3>
</div>
<p class="text-base-content/70 mt-1">{connection.name}</p>
@@ -65,7 +61,7 @@
<button
phx-click="delete"
phx-value-id={connection.id}
data-confirm={"Disconnect from #{String.capitalize(connection.provider_type)}? Your synced products will remain in your shop."}
data-confirm={"Disconnect from #{provider_name(connection.provider_type)}? Your synced products will remain in your shop."}
class="admin-btn admin-btn-ghost admin-btn-sm text-error"
>
Disconnect

View File

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

View File

@@ -12,14 +12,17 @@ defmodule BerrypodWeb.Auth.Login do
<.header>
<p>Log in</p>
<:subtitle>
<%= if @current_scope do %>
You need to reauthenticate to perform sensitive actions on your account.
<% else %>
Don't have an account? <.link
navigate={~p"/users/register"}
class="font-semibold text-brand hover:underline"
phx-no-format
>Sign up</.link> for an account now.
<%= cond do %>
<% @current_scope -> %>
You need to reauthenticate to perform sensitive actions on your account.
<% @registration_open -> %>
Don't have an account? <.link
navigate={~p"/setup"}
class="font-semibold text-brand hover:underline"
phx-no-format
>Set up your shop</.link> to get started.
<% true -> %>
Log in with your admin credentials.
<% end %>
</:subtitle>
</.header>
@@ -100,7 +103,8 @@ defmodule BerrypodWeb.Auth.Login do
form = to_form(%{"email" => email}, as: "user")
{:ok, assign(socket, form: form, trigger_submit: false)}
{:ok,
assign(socket, form: form, trigger_submit: false, registration_open: !Accounts.has_admin?())}
end
@impl true

View File

@@ -54,8 +54,8 @@ defmodule BerrypodWeb.Auth.Registration do
|> put_flash(:error, "Registration is closed")
|> redirect(to: ~p"/users/log-in")}
else
changeset = Accounts.change_user_email(%User{}, %{}, validate_unique: false)
{:ok, assign_form(socket, changeset), temporary_assigns: [form: nil]}
# Fresh install — account creation happens on the setup page
{:ok, redirect(socket, to: ~p"/setup")}
end
end

View File

@@ -0,0 +1,559 @@
defmodule BerrypodWeb.Setup.Onboarding do
use BerrypodWeb, :live_view
alias Berrypod.{Accounts, Products, Settings, Setup}
alias Berrypod.Products.ProviderConnection
alias Berrypod.Providers.Provider
alias Berrypod.Stripe.Setup, as: StripeSetup
# ── Mount ──
@impl true
def mount(_params, _session, socket) do
setup = Setup.setup_status()
cond do
setup.site_live ->
{:ok, push_navigate(socket, to: ~p"/")}
setup.setup_complete ->
{:ok, push_navigate(socket, to: ~p"/admin")}
setup.admin_created and is_nil(get_user(socket)) ->
{:ok, push_navigate(socket, to: ~p"/users/log-in")}
true ->
{:ok, mount_setup(socket, setup)}
end
end
defp get_user(socket) do
case socket.assigns do
%{current_scope: %{user: user}} when not is_nil(user) -> user
_ -> nil
end
end
defp mount_setup(socket, setup) do
provider_conn = Products.get_first_provider_connection()
socket
|> assign(:page_title, "Set up your shop")
|> assign(:setup, setup)
# Account
|> assign(:account_form, to_form(%{"email" => ""}, as: :account))
|> assign(:account_submitted, false)
# Provider
|> assign(:providers, Provider.all())
|> assign(:selected_provider, nil)
|> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider))
|> assign(:provider_testing, false)
|> assign(:provider_test_result, nil)
|> assign(:provider_connecting, false)
|> assign(:provider_conn, provider_conn)
|> assign(:pending_provider_key, nil)
# Stripe
|> assign(:stripe_form, to_form(%{"api_key" => ""}, as: :stripe))
|> assign(:stripe_connecting, false)
end
# ── Events: Account ──
@impl true
def handle_event("create_account", %{"account" => %{"email" => email}}, socket) do
if email == "" do
{:noreply, put_flash(socket, :error, "Please enter your email address")}
else
case Accounts.register_user(%{email: email}) do
{:ok, user} ->
{:ok, _} =
Accounts.deliver_login_instructions(
user,
&url(~p"/users/log-in/#{&1}")
)
setup = %{socket.assigns.setup | admin_created: true}
{:noreply,
socket
|> assign(:setup, setup)
|> assign(:account_submitted, true)
|> put_flash(:info, "Check your email for a login link")}
{:error, changeset} ->
{:noreply,
socket
|> assign(:account_form, to_form(changeset, as: :account))
|> put_flash(:error, "Could not create account")}
end
end
end
# ── Events: Provider ──
def handle_event("select_provider", %{"type" => type}, socket) do
{:noreply,
socket
|> assign(:selected_provider, type)
|> assign(:provider_form, to_form(%{"api_key" => ""}, as: :provider))
|> assign(:provider_test_result, nil)
|> assign(:pending_provider_key, nil)}
end
def handle_event("validate_provider", %{"provider" => params}, socket) do
{:noreply, assign(socket, pending_provider_key: params["api_key"])}
end
def handle_event("test_provider", _params, socket) do
type = socket.assigns.selected_provider
api_key = socket.assigns.pending_provider_key
if api_key in [nil, ""] do
{:noreply, assign(socket, provider_test_result: {:error, :no_api_key})}
else
socket = assign(socket, provider_testing: true, provider_test_result: nil)
temp_conn = %ProviderConnection{
provider_type: type,
api_key_encrypted: encrypt_api_key(api_key)
}
result = Berrypod.Providers.test_connection(temp_conn)
{:noreply, assign(socket, provider_testing: false, provider_test_result: result)}
end
end
def handle_event("connect_provider", %{"provider" => %{"api_key" => api_key}}, socket) do
type = socket.assigns.selected_provider
if api_key == "" do
{:noreply, put_flash(socket, :error, "Please enter your API token")}
else
socket = assign(socket, provider_connecting: true)
params =
%{"api_key" => api_key, "provider_type" => type}
|> maybe_add_shop_config(socket.assigns.provider_test_result)
|> maybe_add_name(socket.assigns.provider_test_result, type)
case Products.create_provider_connection(params) do
{:ok, connection} ->
Products.enqueue_sync(connection)
setup = %{
socket.assigns.setup
| provider_connected: true,
provider_type: type
}
{:noreply,
socket
|> assign(:provider_connecting, false)
|> assign(:provider_conn, connection)
|> assign(:setup, setup)
|> put_flash(:info, "Connected! Product sync started in the background.")}
{:error, _changeset} ->
{:noreply,
socket
|> assign(:provider_connecting, false)
|> put_flash(:error, "Failed to save connection")}
end
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} ->
setup = %{socket.assigns.setup | stripe_connected: true}
setup =
if setup.admin_created and setup.provider_connected do
%{setup | setup_complete: true}
else
setup
end
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> assign(:setup, setup)
|> put_flash(:info, "Stripe connected")}
{:error, message} ->
{:noreply,
socket
|> assign(:stripe_connecting, false)
|> put_flash(:error, "Stripe connection failed: #{message}")}
end
end
end
# ── Render ──
@impl true
def render(assigns) do
~H"""
<div class="setup-page">
<div class="setup-header">
<h1 class="setup-title">Set up your shop</h1>
<p class="setup-subtitle">Three quick steps to get everything connected.</p>
</div>
<div class="setup-sections">
<%!-- Section 1: Account --%>
<.section_card
title="Create admin account"
number={1}
done={@setup.admin_created}
summary={account_summary(assigns)}
hidden={false}
>
<.account_section
form={@account_form}
submitted={@account_submitted}
local_mail?={local_mail_adapter?()}
/>
</.section_card>
<%!-- Section 2: Provider --%>
<.section_card
title="Connect a print provider"
number={2}
done={@setup.provider_connected}
summary={provider_summary(assigns)}
hidden={false}
>
<.provider_section
providers={@providers}
selected={@selected_provider}
form={@provider_form}
testing={@provider_testing}
test_result={@provider_test_result}
connecting={@provider_connecting}
/>
</.section_card>
<%!-- Section 3: Payments --%>
<.section_card
title="Connect payments"
number={3}
done={@setup.stripe_connected}
summary={stripe_summary(assigns)}
hidden={false}
>
<.stripe_section
form={@stripe_form}
connecting={@stripe_connecting}
/>
</.section_card>
</div>
<%!-- All done --%>
<div :if={@setup.setup_complete} class="setup-complete">
<.icon name="hero-check-badge" class="setup-complete-icon" />
<h2>You're all set</h2>
<p>Head to the dashboard to sync products, customise your theme, and go live.</p>
<.link navigate={~p"/admin"} class="admin-btn admin-btn-primary">
Go to dashboard <span aria-hidden="true">&rarr;</span>
</.link>
</div>
</div>
"""
end
# ── Section card component ──
attr :title, :string, required: true
attr :number, :integer, required: true
attr :done, :boolean, required: true
attr :summary, :string, default: nil
attr :hidden, :boolean, default: false
slot :inner_block, required: true
defp section_card(assigns) do
~H"""
<div :if={!@hidden} class={["setup-card", @done && "setup-card-done"]}>
<div class="setup-card-header">
<span class={["setup-card-number", @done && "setup-card-number-done"]}>
<%= if @done do %>
<.icon name="hero-check-mini" class="size-4" />
<% else %>
{@number}
<% end %>
</span>
<h2 class="setup-card-title">{@title}</h2>
</div>
<%= if @done and @summary do %>
<p class="setup-card-summary">{@summary}</p>
<% else %>
<div :if={!@done} class="setup-card-body">
{render_slot(@inner_block)}
</div>
<% end %>
</div>
"""
end
# ── Account section ──
attr :form, :any, required: true
attr :submitted, :boolean, required: true
attr :local_mail?, :boolean, required: true
defp account_section(assigns) do
~H"""
<div :if={@submitted} class="admin-alert admin-alert-info">
<.icon name="hero-envelope" class="size-5 shrink-0" />
<div>
<p><strong>Check your email</strong></p>
<p>Click the link we sent to finish creating your account.</p>
</div>
</div>
<div :if={@local_mail? and @submitted} class="admin-alert admin-alert-info">
<.icon name="hero-information-circle" class="size-5 shrink-0" />
<div>
<p>
Using local mail adapter.
See sent emails at <a href="/dev/mailbox" class="underline">/dev/mailbox</a>.
</p>
</div>
</div>
<div :if={!@submitted}>
<p class="setup-hint">Enter your email to create the admin account. We'll send a login link.</p>
<.form for={@form} phx-submit="create_account">
<.input
field={@form[:email]}
type="email"
label="Email address"
autocomplete="email"
required
phx-mounted={JS.focus()}
/>
<div class="setup-actions">
<.button phx-disable-with="Creating account...">Create account</.button>
</div>
</.form>
</div>
"""
end
# ── Provider section ──
attr :providers, :list, required: true
attr :selected, :string, default: nil
attr :form, :any, required: true
attr :testing, :boolean, required: true
attr :test_result, :any, default: nil
attr :connecting, :boolean, required: true
defp provider_section(assigns) do
~H"""
<div>
<p class="setup-hint">Choose a print-on-demand provider and connect your API key.</p>
<div class="setup-provider-grid">
<button
:for={provider <- @providers}
type="button"
phx-click={provider.status == :available && "select_provider"}
phx-value-type={provider.type}
disabled={provider.status == :coming_soon}
class={[
"setup-provider-card",
@selected == provider.type && "setup-provider-card-selected",
provider.status == :coming_soon && "setup-provider-card-disabled"
]}
>
<span class="setup-provider-name">{provider.name}</span>
<span class="setup-provider-tagline">{provider.tagline}</span>
<span :if={provider.status == :coming_soon} class="setup-provider-badge">
Coming soon
</span>
</button>
</div>
<%!-- API key form for selected provider --%>
<div :if={@selected} class="setup-provider-form">
<% provider_info = Enum.find(@providers, &(&1.type == @selected)) %>
<p class="setup-hint">
{provider_info.setup_hint}.
<a href={provider_info.setup_url} target="_blank" rel="noopener" class="setup-link">
Open {provider_info.name} &rarr;
</a>
</p>
<.form for={@form} phx-change="validate_provider" phx-submit="connect_provider">
<.input
field={@form[:api_key]}
type="password"
label="API token"
placeholder="Paste your token here"
autocomplete="off"
/>
<div class="setup-actions">
<button
type="button"
phx-click="test_provider"
disabled={@testing}
class="admin-btn admin-btn-secondary"
>
<%= if @testing do %>
<.icon name="hero-arrow-path" class="size-4 animate-spin" /> Checking...
<% else %>
<.icon name="hero-signal" class="size-4" /> Check connection
<% end %>
</button>
<.button type="submit" disabled={@connecting or @testing}>
{if @connecting, do: "Connecting...", else: "Connect"}
</.button>
</div>
<.provider_test_feedback :if={@test_result} result={@test_result} />
</.form>
</div>
</div>
"""
end
attr :result, :any, required: true
defp provider_test_feedback(assigns) do
~H"""
<div class="setup-test-result">
<%= case @result do %>
<% {:ok, info} -> %>
<span class="setup-test-ok">
<.icon name="hero-check-circle" class="size-4" />
Connected{if info[:shop_name], do: " to #{info.shop_name}", else: ""}
</span>
<% {:error, :no_api_key} -> %>
<span class="setup-test-error">
<.icon name="hero-x-circle" class="size-4" /> Please enter your API token
</span>
<% {:error, reason} -> %>
<span class="setup-test-error">
<.icon name="hero-x-circle" class="size-4" /> {format_error(reason)}
</span>
<% end %>
</div>
"""
end
# ── Stripe section ──
attr :form, :any, required: true
attr :connecting, :boolean, required: true
defp stripe_section(assigns) do
~H"""
<div>
<p class="setup-hint">
Enter your Stripe secret key to accept payments.
<a
href="https://dashboard.stripe.com/apikeys"
target="_blank"
rel="noopener"
class="setup-link"
>
Open Stripe dashboard &rarr;
</a>
</p>
<.form for={@form} phx-submit="connect_stripe">
<.input
field={@form[:api_key]}
type="password"
label="Secret key"
autocomplete="off"
placeholder="sk_test_... or sk_live_..."
/>
<p class="setup-key-hint">
Starts with <code>sk_test_</code> or <code>sk_live_</code>. Encrypted at rest.
</p>
<div class="setup-actions">
<.button phx-disable-with="Connecting...">
{if @connecting, do: "Connecting...", else: "Connect Stripe"}
</.button>
</div>
</.form>
</div>
"""
end
# ── Helpers ──
defp account_summary(%{current_scope: %{user: user}}) when not is_nil(user) do
user.email
end
defp account_summary(_), do: "Account created"
defp provider_summary(%{setup: %{provider_type: type}}) when is_binary(type) do
case Provider.get(type) do
nil -> "Connected"
info -> "Connected to #{info.name}"
end
end
defp provider_summary(_), do: nil
defp stripe_summary(%{setup: %{stripe_connected: true}}) do
case Settings.secret_hint("stripe_api_key") do
nil -> "Connected"
hint -> "Connected · #{hint}"
end
end
defp stripe_summary(_), do: nil
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: name}}, _type) when is_binary(name) do
Map.put_new(params, "name", name)
end
defp maybe_add_name(params, _, type) do
case Provider.get(type) do
nil -> Map.put_new(params, "name", type)
info -> Map.put_new(params, "name", info.name)
end
end
defp format_error(:unauthorized), do: "That token doesn't seem to be valid"
defp format_error(:timeout), do: "Couldn't reach the provider — try again"
defp format_error(:provider_not_implemented), do: "This provider isn't supported yet"
defp format_error({:http_error, _code}), do: "Something went wrong — try again"
defp format_error(error) when is_binary(error), do: error
defp format_error(_), do: "Connection failed — check your token and try again"
defp local_mail_adapter? do
Application.get_env(:berrypod, Berrypod.Mailer)[:adapter] == Swoosh.Adapters.Local
end
end

View File

@@ -140,6 +140,16 @@ defmodule BerrypodWeb.Router do
end
end
# Setup page — minimal live_session, no theme/cart/search hooks
scope "/", BerrypodWeb do
pipe_through [:browser]
live_session :setup,
on_mount: [{BerrypodWeb.UserAuth, :mount_current_scope}] do
live "/setup", Setup.Onboarding, :index
end
end
## Authentication routes
# Admin pages with sidebar layout
@@ -153,7 +163,6 @@ defmodule BerrypodWeb.Router do
{BerrypodWeb.AdminLayoutHook, :assign_current_path}
] do
live "/", Admin.Dashboard, :index
live "/setup", Admin.Setup, :index
live "/orders", Admin.Orders, :index
live "/orders/:id", Admin.OrderShow, :show
live "/products", Admin.Products, :index

View File

@@ -57,8 +57,8 @@ defmodule BerrypodWeb.ThemeHook do
{:cont, socket}
not Berrypod.Accounts.has_admin?() ->
# Fresh install — send to registration
{:halt, Phoenix.LiveView.redirect(socket, to: "/users/register")}
# Fresh install — send to setup
{:halt, Phoenix.LiveView.redirect(socket, to: "/setup")}
true ->
{:halt, Phoenix.LiveView.redirect(socket, to: "/coming-soon")}

View File

@@ -258,7 +258,7 @@ defmodule BerrypodWeb.UserAuth do
@doc "Returns the path to redirect to after log in."
def signed_in_path(_) do
if Berrypod.Settings.site_live?(), do: ~p"/admin", else: ~p"/admin/setup"
if Berrypod.Setup.setup_status().setup_complete, do: ~p"/admin", else: ~p"/setup"
end
@doc """