add setup onboarding page, dashboard launch checklist, provider registry

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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