make admin provider UI support both Printify and Printful

- Provider form accepts ?type= query param (printify/printful)
- Conditional setup instructions per provider (API key steps, login URLs)
- Dynamic labels, titles, and config handling (shop_id vs store_id)
- Provider index shows dropdown with both provider options
- Settings page renamed from @printify to @provider (generic)
- Fix Printful shipping rates: add default state codes for US/CA/AU

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-15 10:53:15 +00:00
parent 24d61f7a9e
commit 61cb2b7a87
7 changed files with 192 additions and 86 deletions

View File

@ -198,8 +198,9 @@ defmodule SimpleshopTheme.Providers.Printful do
defp fetch_rate_for_product(catalog_product_id, variant_id, country_code) do defp fetch_rate_for_product(catalog_product_id, variant_id, country_code) do
items = [%{source: "catalog", catalog_variant_id: variant_id, quantity: 1}] items = [%{source: "catalog", catalog_variant_id: variant_id, quantity: 1}]
recipient = build_recipient(country_code)
case Client.calculate_shipping(%{country_code: country_code}, items) do case Client.calculate_shipping(recipient, items) do
{:ok, rates} when is_list(rates) -> {:ok, rates} when is_list(rates) ->
standard = Enum.find(rates, &(&1["shipping"] == "STANDARD")) || List.first(rates) standard = Enum.find(rates, &(&1["shipping"] == "STANDARD")) || List.first(rates)
@ -228,6 +229,18 @@ defmodule SimpleshopTheme.Providers.Printful do
end end
end end
# Printful requires state_code for US, CA, and AU
@default_state_codes %{"US" => "NY", "CA" => "ON", "AU" => "NSW"}
defp build_recipient(country_code) do
base = %{country_code: country_code}
case @default_state_codes[country_code] do
nil -> base
state -> Map.put(base, :state_code, state)
end
end
# Returns {catalog_product_id, first_catalog_variant_id} per product # Returns {catalog_product_id, first_catalog_variant_id} per product
defp extract_per_product_items(products) do defp extract_per_product_items(products) do
products products

View File

@ -5,15 +5,20 @@ defmodule SimpleshopThemeWeb.Admin.Providers.Form do
alias SimpleshopTheme.Products.ProviderConnection alias SimpleshopTheme.Products.ProviderConnection
alias SimpleshopTheme.Providers alias SimpleshopTheme.Providers
@supported_types ~w(printify printful)
@impl true @impl true
def mount(params, _session, socket) do def mount(params, _session, socket) do
{:ok, apply_action(socket, socket.assigns.live_action, params)} {:ok, apply_action(socket, socket.assigns.live_action, params)}
end end
defp apply_action(socket, :new, _params) do defp apply_action(socket, :new, params) do
provider_type = validated_type(params["type"])
socket socket
|> assign(:page_title, "Connect to Printify") |> assign(:page_title, "Connect to #{provider_label(provider_type)}")
|> assign(:connection, %ProviderConnection{provider_type: "printify"}) |> assign(:provider_type, provider_type)
|> assign(:connection, %ProviderConnection{provider_type: provider_type})
|> assign(:form, to_form(ProviderConnection.changeset(%ProviderConnection{}, %{}))) |> assign(:form, to_form(ProviderConnection.changeset(%ProviderConnection{}, %{})))
|> assign(:testing, false) |> assign(:testing, false)
|> assign(:test_result, nil) |> assign(:test_result, nil)
@ -24,7 +29,8 @@ defmodule SimpleshopThemeWeb.Admin.Providers.Form do
connection = Products.get_provider_connection!(id) connection = Products.get_provider_connection!(id)
socket socket
|> assign(:page_title, "Printify settings") |> assign(:page_title, "#{provider_label(connection.provider_type)} settings")
|> assign(:provider_type, connection.provider_type)
|> assign(:connection, connection) |> assign(:connection, connection)
|> assign(:form, to_form(ProviderConnection.changeset(connection, %{}))) |> assign(:form, to_form(ProviderConnection.changeset(connection, %{})))
|> assign(:testing, false) |> assign(:testing, false)
@ -48,14 +54,13 @@ defmodule SimpleshopThemeWeb.Admin.Providers.Form do
def handle_event("test_connection", _params, socket) do def handle_event("test_connection", _params, socket) do
socket = assign(socket, testing: true, test_result: nil) socket = assign(socket, testing: true, test_result: nil)
# Use pending_api_key from validation, or fall back to existing encrypted key
api_key = api_key =
socket.assigns[:pending_api_key] || socket.assigns[:pending_api_key] ||
ProviderConnection.get_api_key(socket.assigns.connection) ProviderConnection.get_api_key(socket.assigns.connection)
if api_key && api_key != "" do if api_key && api_key != "" do
temp_conn = %ProviderConnection{ temp_conn = %ProviderConnection{
provider_type: "printify", provider_type: socket.assigns.provider_type,
api_key_encrypted: encrypt_api_key(api_key) api_key_encrypted: encrypt_api_key(api_key)
} }
@ -72,17 +77,19 @@ defmodule SimpleshopThemeWeb.Admin.Providers.Form do
end end
defp save_connection(socket, :new, params) do defp save_connection(socket, :new, params) do
provider_type = socket.assigns.provider_type
params = params =
params params
|> Map.put("provider_type", "printify") |> Map.put("provider_type", provider_type)
|> maybe_add_shop_config(socket.assigns.test_result) |> maybe_add_config(provider_type, socket.assigns.test_result)
|> maybe_add_name(socket.assigns.test_result) |> maybe_add_name(provider_type, socket.assigns.test_result)
case Products.create_provider_connection(params) do case Products.create_provider_connection(params) do
{:ok, _connection} -> {:ok, _connection} ->
{:noreply, {:noreply,
socket socket
|> put_flash(:info, "Connected to Printify!") |> put_flash(:info, "Connected to #{provider_label(provider_type)}!")
|> push_navigate(to: ~p"/admin/settings")} |> push_navigate(to: ~p"/admin/settings")}
{:error, %Ecto.Changeset{} = changeset} -> {:error, %Ecto.Changeset{} = changeset} ->
@ -103,19 +110,29 @@ defmodule SimpleshopThemeWeb.Admin.Providers.Form do
end end
end end
defp maybe_add_shop_config(params, {:ok, %{shop_id: shop_id}}) do # Printify returns shop_id, Printful returns store_id
defp maybe_add_config(params, "printify", {:ok, %{shop_id: shop_id}}) do
config = Map.get(params, "config", %{}) |> Map.put("shop_id", to_string(shop_id)) config = Map.get(params, "config", %{}) |> Map.put("shop_id", to_string(shop_id))
Map.put(params, "config", config) Map.put(params, "config", config)
end end
defp maybe_add_shop_config(params, _), do: params defp maybe_add_config(params, "printful", {:ok, %{store_id: store_id}}) do
config = Map.get(params, "config", %{}) |> Map.put("store_id", to_string(store_id))
defp maybe_add_name(params, {:ok, %{shop_name: shop_name}}) when is_binary(shop_name) do Map.put(params, "config", config)
Map.put_new(params, "name", shop_name)
end end
defp maybe_add_name(params, _) do defp maybe_add_config(params, _type, _result), do: params
Map.put_new(params, "name", "Printify")
defp maybe_add_name(params, "printify", {:ok, %{shop_name: name}}) when is_binary(name) do
Map.put_new(params, "name", name)
end
defp maybe_add_name(params, "printful", {:ok, %{store_name: name}}) when is_binary(name) do
Map.put_new(params, "name", name)
end
defp maybe_add_name(params, type, _result) do
Map.put_new(params, "name", provider_label(type))
end end
defp encrypt_api_key(api_key) do defp encrypt_api_key(api_key) do
@ -125,9 +142,21 @@ defmodule SimpleshopThemeWeb.Admin.Providers.Form do
end end
end end
defp format_error(:no_api_key), do: "Please enter your connection key" defp validated_type(type) when type in @supported_types, do: type
defp validated_type(_), do: "printify"
# 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
defp format_error(:no_api_key), do: "Please enter your API key"
defp format_error(:unauthorized), do: "That key doesn't seem to be valid" defp format_error(:unauthorized), do: "That key doesn't seem to be valid"
defp format_error(:timeout), do: "Couldn't reach Printify - try again" defp format_error(:timeout), do: "Couldn't reach the provider - try again"
defp format_error({:http_error, _code}), do: "Something went wrong - try again" 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(error) when is_binary(error), do: error
defp format_error(_), do: "Connection failed - check your key and try again" defp format_error(_), do: "Connection failed - check your key and try again"

View File

@ -1,18 +1,21 @@
<.header> <.header>
{if @live_action == :new, do: "Connect to Printify", else: "Printify settings"} {if @live_action == :new,
do: "Connect to #{provider_label(@provider_type)}",
else: "#{provider_label(@provider_type)} settings"}
</.header> </.header>
<div class="max-w-xl mt-6"> <div class="max-w-xl mt-6">
<%= if @live_action == :new do %> <%= if @live_action == :new do %>
<div class="prose prose-sm mb-6"> <div class="prose prose-sm mb-6">
<p> <p>
Printify is a print-on-demand service that prints and ships products for you. {provider_label(@provider_type)} is a print-on-demand service that prints and ships products for you.
Connect your account to automatically import your products into your shop. Connect your account to automatically import your products into your shop.
</p> </p>
</div> </div>
<%= if @provider_type == "printify" do %>
<div class="rounded-lg bg-base-200 p-4 mb-6 text-sm"> <div class="rounded-lg bg-base-200 p-4 mb-6 text-sm">
<p class="font-medium mb-2">Get your connection key from Printify:</p> <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"> <ol class="list-decimal list-inside space-y-1 text-base-content/80">
<li> <li>
<a <a
@ -40,15 +43,42 @@
<li>Click <strong>Copy to clipboard</strong> and paste it below</li> <li>Click <strong>Copy to clipboard</strong> and paste it below</li>
</ol> </ol>
</div> </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="link"
>
Log in to Printful
</a>
(or <a
href="https://www.printful.com/auth/signup"
target="_blank"
rel="noopener"
class="link"
>create a free account</a>)
</li>
<li>Go to <strong>Settings</strong> &rarr; <strong>API access</strong></li>
<li>Click <strong>Create API key</strong></li>
<li>Give it a name and select <strong>all scopes</strong></li>
<li>Copy the token and paste it below</li>
</ol>
</div>
<% end %>
<% end %> <% end %>
<.form for={@form} id="provider-form" phx-change="validate" phx-submit="save"> <.form for={@form} id="provider-form" phx-change="validate" phx-submit="save">
<input type="hidden" name="provider_connection[provider_type]" value="printify" /> <input type="hidden" name="provider_connection[provider_type]" value={@provider_type} />
<.input <.input
field={@form[:api_key]} field={@form[:api_key]}
type="password" type="password"
label="Printify connection key" label={"#{provider_label(@provider_type)} API key"}
placeholder={ placeholder={
if @live_action == :edit, if @live_action == :edit,
do: "Leave blank to keep current key", do: "Leave blank to keep current key",
@ -73,9 +103,10 @@
<div :if={@test_result} class="text-sm"> <div :if={@test_result} class="text-sm">
<%= case @test_result do %> <%= case @test_result do %>
<% {:ok, info} -> %> <% {:ok, _info} -> %>
<span class="text-success flex items-center gap-1"> <span class="text-success flex items-center gap-1">
<.icon name="hero-check-circle" class="size-4" /> Connected to {info.shop_name} <.icon name="hero-check-circle" class="size-4" />
Connected to {connection_name(@test_result) || provider_label(@provider_type)}
</span> </span>
<% {:error, reason} -> %> <% {:error, reason} -> %>
<span class="text-error flex items-center gap-1"> <span class="text-error flex items-center gap-1">
@ -92,7 +123,9 @@
<div class="flex gap-2 mt-6"> <div class="flex gap-2 mt-6">
<.button type="submit" disabled={@testing}> <.button type="submit" disabled={@testing}>
{if @live_action == :new, do: "Connect to Printify", else: "Save changes"} {if @live_action == :new,
do: "Connect to #{provider_label(@provider_type)}",
else: "Save changes"}
</.button> </.button>
<.link navigate={~p"/admin/providers"} class="btn btn-ghost"> <.link navigate={~p"/admin/providers"} class="btn btn-ghost">
Cancel Cancel

View File

@ -1,23 +1,38 @@
<.header> <.header>
Providers Providers
<:actions> <:actions>
<.button navigate={~p"/admin/providers/new"}> <div class="dropdown dropdown-end">
<.icon name="hero-plus" class="size-4 mr-1" /> Connect Printify <div tabindex="0" role="button" class="btn btn-primary">
</.button> <.icon name="hero-plus" class="size-4 mr-1" /> Connect provider
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-52 z-10">
<li>
<.link navigate={~p"/admin/providers/new?type=printify"}>Printify</.link>
</li>
<li>
<.link navigate={~p"/admin/providers/new?type=printful"}>Printful</.link>
</li>
</ul>
</div>
</:actions> </:actions>
</.header> </.header>
<div id="connections" phx-update="stream" class="mt-6 space-y-4"> <div id="connections" phx-update="stream" class="mt-6 space-y-4">
<div id="connections-empty" class="hidden only:block text-center py-12"> <div id="connections-empty" class="hidden only:block text-center py-12">
<.icon name="hero-cube" class="size-16 mx-auto mb-4 text-base-content/30" /> <.icon name="hero-cube" class="size-16 mx-auto mb-4 text-base-content/30" />
<h2 class="text-xl font-medium">Connect your Printify account</h2> <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"> <p class="mt-2 text-base-content/60 max-w-md mx-auto">
Printify handles printing and shipping for you. Connect your account Connect your Printify or Printful account to import products
to import your products and start selling. and start selling.
</p> </p>
<.button navigate={~p"/admin/providers/new"} class="mt-6"> <div class="flex justify-center gap-3 mt-6">
Connect to Printify <.button navigate={~p"/admin/providers/new?type=printify"}>
Connect Printify
</.button> </.button>
<.button navigate={~p"/admin/providers/new?type=printful"} class="btn-outline">
Connect Printful
</.button>
</div>
</div> </div>
<div <div
@ -50,7 +65,7 @@
<button <button
phx-click="delete" phx-click="delete"
phx-value-id={connection.id} phx-value-id={connection.id}
data-confirm="Disconnect from Printify? Your synced products will remain in your shop." data-confirm={"Disconnect from #{String.capitalize(connection.provider_type)}? Your synced products will remain in your shop."}
class="btn btn-ghost btn-sm text-error" class="btn btn-ghost btn-sm text-error"
> >
Disconnect Disconnect

View File

@ -59,7 +59,7 @@ defmodule SimpleshopThemeWeb.Admin.Settings do
nil nil
end end
assign(socket, :printify, connection_info) assign(socket, :provider, connection_info)
end end
# -- Account assigns -- # -- Account assigns --
@ -339,7 +339,7 @@ defmodule SimpleshopThemeWeb.Admin.Settings do
<section class="mt-10"> <section class="mt-10">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h2 class="text-lg font-semibold">Products</h2> <h2 class="text-lg font-semibold">Products</h2>
<%= if @printify do %> <%= if @provider do %>
<.status_pill color="green"> <.status_pill color="green">
<.icon name="hero-check-circle-mini" class="size-3" /> Connected <.icon name="hero-check-circle-mini" class="size-3" /> Connected
</.status_pill> </.status_pill>
@ -348,8 +348,8 @@ defmodule SimpleshopThemeWeb.Admin.Settings do
<% end %> <% end %>
</div> </div>
<%= if @printify do %> <%= if @provider do %>
<.printify_connected printify={@printify} /> <.provider_connected provider={@provider} />
<% else %> <% else %>
<div class="mt-4"> <div class="mt-4">
<p class="text-sm text-base-content/60"> <p class="text-sm text-base-content/60">
@ -357,10 +357,10 @@ defmodule SimpleshopThemeWeb.Admin.Settings do
</p> </p>
<div class="mt-4"> <div class="mt-4">
<.link <.link
navigate={~p"/admin/providers/new"} navigate={~p"/admin/providers"}
class="inline-flex items-center gap-2 rounded-md bg-base-content px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-base-content/80" class="inline-flex items-center gap-2 rounded-md bg-base-content px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-base-content/80"
> >
<.icon name="hero-plus-mini" class="size-4" /> Connect to Printify <.icon name="hero-plus-mini" class="size-4" /> Connect a provider
</.link> </.link>
</div> </div>
</div> </div>
@ -474,23 +474,24 @@ defmodule SimpleshopThemeWeb.Admin.Settings do
""" """
end end
attr :printify, :map, required: true attr :provider, :map, required: true
defp printify_connected(assigns) do defp provider_connected(assigns) do
conn = assigns.printify.connection conn = assigns.provider.connection
assigns = assigns =
assigns assigns
|> assign(:connection, conn) |> assign(:connection, conn)
|> assign(:product_count, assigns.printify.product_count) |> assign(:product_count, assigns.provider.product_count)
|> assign(:syncing, conn.sync_status == "syncing") |> assign(:syncing, conn.sync_status == "syncing")
|> assign(:provider_label, String.capitalize(conn.provider_type))
~H""" ~H"""
<div class="mt-4"> <div class="mt-4">
<dl class="text-sm"> <dl class="text-sm">
<div class="flex gap-2 py-1"> <div class="flex gap-2 py-1">
<dt class="text-base-content/60 w-28 shrink-0">Provider</dt> <dt class="text-base-content/60 w-28 shrink-0">Provider</dt>
<dd class="text-base-content">Printify</dd> <dd class="text-base-content">{@provider_label}</dd>
</div> </div>
<div class="flex gap-2 py-1"> <div class="flex gap-2 py-1">
<dt class="text-base-content/60 w-28 shrink-0">Shop</dt> <dt class="text-base-content/60 w-28 shrink-0">Shop</dt>
@ -534,7 +535,7 @@ defmodule SimpleshopThemeWeb.Admin.Settings do
<button <button
phx-click="delete_connection" phx-click="delete_connection"
phx-value-id={@connection.id} phx-value-id={@connection.id}
data-confirm="Disconnect from Printify? Your synced products will remain in your shop." data-confirm={"Disconnect from #{@provider_label}? Your synced products will remain in your shop."}
class="text-sm text-red-600 hover:text-red-800 px-2 py-1.5" class="text-sm text-red-600 hover:text-red-800 px-2 py-1.5"
> >
Disconnect Disconnect

View File

@ -33,13 +33,14 @@ defmodule SimpleshopThemeWeb.Admin.ProvidersTest do
test "shows empty state when no connections exist", %{conn: conn} do test "shows empty state when no connections exist", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/providers") {:ok, _view, html} = live(conn, ~p"/admin/providers")
assert html =~ "Connect your Printify account" assert html =~ "Connect a print-on-demand provider"
end end
test "shows connect button", %{conn: conn} do test "shows connect buttons for both providers", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/providers") {:ok, view, _html} = live(conn, ~p"/admin/providers")
assert has_element?(view, ~s(a[href="/admin/providers/new"])) assert has_element?(view, ~s(a[href="/admin/providers/new?type=printify"]))
assert has_element?(view, ~s(a[href="/admin/providers/new?type=printful"]))
end end
end end
@ -138,12 +139,26 @@ defmodule SimpleshopThemeWeb.Admin.ProvidersTest do
%{conn: log_in_user(conn, user)} %{conn: log_in_user(conn, user)}
end end
test "renders new form", %{conn: conn} do test "renders new Printify form", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/providers/new?type=printify")
assert html =~ "Connect to Printify"
assert html =~ "Printify API key"
assert html =~ "Log in to Printify"
end
test "renders new Printful form", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/providers/new?type=printful")
assert html =~ "Connect to Printful"
assert html =~ "Printful API key"
assert html =~ "Log in to Printful"
end
test "defaults to Printify when no type param", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/providers/new") {:ok, _view, html} = live(conn, ~p"/admin/providers/new")
assert html =~ "Connect to Printify" assert html =~ "Connect to Printify"
assert html =~ "connection key"
assert html =~ "Log in to Printify"
end end
test "test connection shows error when no api key", %{conn: conn} do test "test connection shows error when no api key", %{conn: conn} do
@ -151,7 +166,7 @@ defmodule SimpleshopThemeWeb.Admin.ProvidersTest do
html = render_click(view, "test_connection") html = render_click(view, "test_connection")
assert html =~ "Please enter your connection key" assert html =~ "Please enter your API key"
end end
test "saves new connection", %{conn: conn} do test "saves new connection", %{conn: conn} do

View File

@ -151,7 +151,7 @@ defmodule SimpleshopThemeWeb.Admin.SettingsTest do
assert html =~ "Products" assert html =~ "Products"
assert html =~ "Not connected" assert html =~ "Not connected"
assert has_element?(view, ~s(a[href="/admin/providers/new"]), "Connect to Printify") assert has_element?(view, ~s(a[href="/admin/providers"]), "Connect a provider")
end end
test "shows connection info when provider connected", %{conn: conn} do test "shows connection info when provider connected", %{conn: conn} do