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
|
||||
|
||||
Reference in New Issue
Block a user