berrypod/lib/berrypod/providers/provider.ex
jamey c2caeed64d 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>
2026-02-20 00:34:06 +00:00

161 lines
5.5 KiB
Elixir

defmodule Berrypod.Providers.Provider do
@moduledoc """
Behaviour and registry for POD provider integrations.
Each provider (Printify, Printful, etc.) implements this behaviour
to provide a consistent interface for:
- Testing connections
- Fetching products
- 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:
- Products are maps with keys: `title`, `description`, `provider_product_id`,
`images`, `variants`, `category`, `provider_data`
- Variants are maps with keys: `provider_variant_id`, `title`, `sku`, `price`,
`cost`, `options`, `is_enabled`, `is_available`
- Images are maps with keys: `src`, `position`, `alt`
"""
alias Berrypod.Products.ProviderConnection
# 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()
@callback test_connection(ProviderConnection.t()) :: {:ok, map()} | {:error, term()}
@callback fetch_products(ProviderConnection.t()) :: {:ok, [map()]} | {:error, term()}
@callback submit_order(ProviderConnection.t(), order :: map()) ::
{:ok, %{provider_order_id: String.t()}} | {:error, term()}
@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.
Optional — providers that don't support shipping rate lookup can skip this.
The sync worker checks `function_exported?/3` before calling.
"""
@callback fetch_shipping_rates(ProviderConnection.t(), products :: [map()]) ::
{:ok, [map()]} | {:error, term()}
@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 the `@providers` list.
"""
def for_type(type) do
case Application.get_env(:berrypod, :provider_modules, %{}) do
modules when is_map(modules) ->
case Map.get(modules, type) do
nil -> default_for_type(type)
module -> {:ok, module}
end
_ ->
default_for_type(type)
end
end
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.
"""
def for_connection(%ProviderConnection{provider_type: type}) do
for_type(type)
end
end