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:
@@ -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.
|
||||
"""
|
||||
|
||||
28
lib/berrypod/payments/registry.ex
Normal file
28
lib/berrypod/payments/registry.ex
Normal 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
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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> → <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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 →
|
||||
</.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"} →
|
||||
</.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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> → <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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 · {@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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
559
lib/berrypod_web/live/setup/onboarding.ex
Normal file
559
lib/berrypod_web/live/setup/onboarding.ex
Normal 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">→</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} →
|
||||
</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 →
|
||||
</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
|
||||
@@ -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
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 """
|
||||
|
||||
Reference in New Issue
Block a user