feat: add admin provider setup UI with improved product sync

- Add /admin/providers LiveView for connecting and managing POD providers
- Implement pagination for Printify API (handles all products, not just first page)
- Add parallel processing (5 concurrent) for faster product sync
- Add slug-based fallback matching when provider_product_id changes
- Add error recovery with try/rescue to prevent stuck sync status
- Add checksum-based change detection to skip unchanged products
- Add upsert tests covering race conditions and slug matching
- Add Printify provider tests
- Document Printify integration research (product identity, order risks,
  open source vs managed hosting implications)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-01-31 22:08:34 +00:00
parent bbd748f123
commit 5b736b99fd
17 changed files with 1352 additions and 38 deletions

View File

@@ -469,4 +469,68 @@ defmodule SimpleshopThemeWeb.CoreComponents do
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
@doc """
Renders a modal dialog.
Uses daisyUI's modal component with proper accessibility.
## Examples
<.modal id="confirm-modal">
Are you sure?
<:actions>
<button class="btn">Cancel</button>
<button class="btn btn-primary">Confirm</button>
</:actions>
</.modal>
"""
attr :id, :string, required: true
attr :show, :boolean, default: false
attr :on_cancel, JS, default: %JS{}
slot :inner_block, required: true
slot :actions
def modal(assigns) do
~H"""
<dialog
id={@id}
class="modal"
phx-mounted={@show && show_modal(@id)}
phx-remove={hide_modal(@id)}
>
<div class="modal-box max-w-lg">
<form method="dialog">
<button
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
phx-click={@on_cancel}
aria-label={gettext("close")}
>
<.icon name="hero-x-mark" class="size-5" />
</button>
</form>
{render_slot(@inner_block)}
<div :if={@actions != []} class="modal-action">
{render_slot(@actions)}
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button phx-click={@on_cancel}>close</button>
</form>
</dialog>
"""
end
def show_modal(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.exec("showModal()", to: "##{id}")
end
def hide_modal(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.exec("close()", to: "##{id}")
|> JS.pop_focus()
end
end

View File

@@ -0,0 +1,134 @@
defmodule SimpleshopThemeWeb.ProviderLive.Form do
use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Products
alias SimpleshopTheme.Products.ProviderConnection
alias SimpleshopTheme.Providers
@impl true
def mount(params, _session, socket) do
{:ok, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "Connect to Printify")
|> assign(:connection, %ProviderConnection{provider_type: "printify"})
|> assign(:form, to_form(ProviderConnection.changeset(%ProviderConnection{}, %{})))
|> assign(:testing, false)
|> assign(:test_result, nil)
|> assign(:pending_api_key, nil)
end
defp apply_action(socket, :edit, %{"id" => id}) do
connection = Products.get_provider_connection!(id)
socket
|> assign(:page_title, "Printify settings")
|> assign(:connection, connection)
|> assign(:form, to_form(ProviderConnection.changeset(connection, %{})))
|> assign(:testing, false)
|> assign(:test_result, nil)
|> assign(:pending_api_key, nil)
end
@impl true
def handle_event("validate", %{"provider_connection" => params}, socket) do
form =
socket.assigns.connection
|> ProviderConnection.changeset(params)
|> Map.put(:action, :validate)
|> to_form()
# Store api_key separately since changeset encrypts it immediately
{:noreply, assign(socket, form: form, pending_api_key: params["api_key"])}
end
@impl true
def handle_event("test_connection", _params, socket) do
socket = assign(socket, testing: true, test_result: nil)
# Use pending_api_key from validation, or fall back to existing encrypted key
api_key =
socket.assigns[:pending_api_key] ||
ProviderConnection.get_api_key(socket.assigns.connection)
if api_key && api_key != "" do
temp_conn = %ProviderConnection{
provider_type: "printify",
api_key_encrypted: encrypt_api_key(api_key)
}
result = Providers.test_connection(temp_conn)
{:noreply, assign(socket, testing: false, test_result: result)}
else
{:noreply, assign(socket, testing: false, test_result: {:error, :no_api_key})}
end
end
@impl true
def handle_event("save", %{"provider_connection" => params}, socket) do
save_connection(socket, socket.assigns.live_action, params)
end
defp save_connection(socket, :new, params) do
params =
params
|> Map.put("provider_type", "printify")
|> maybe_add_shop_config(socket.assigns.test_result)
|> maybe_add_name(socket.assigns.test_result)
case Products.create_provider_connection(params) do
{:ok, _connection} ->
{:noreply,
socket
|> put_flash(:info, "Connected to Printify!")
|> push_navigate(to: ~p"/admin/providers")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp save_connection(socket, :edit, params) do
case Products.update_provider_connection(socket.assigns.connection, params) do
{:ok, _connection} ->
{:noreply,
socket
|> put_flash(:info, "Settings saved")
|> push_navigate(to: ~p"/admin/providers")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
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")
end
defp encrypt_api_key(api_key) do
case SimpleshopTheme.Vault.encrypt(api_key) do
{:ok, encrypted} -> encrypted
_ -> nil
end
end
defp format_error(:no_api_key), do: "Please enter your connection key"
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({: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 key and try again"
end

View File

@@ -0,0 +1,104 @@
<Layouts.app flash={@flash}>
<.header>
{if @live_action == :new, do: "Connect to Printify", else: "Printify settings"}
</.header>
<div class="max-w-xl mt-6">
<%= if @live_action == :new do %>
<div class="prose prose-sm mb-6">
<p>
Printify 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>
<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>
<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="link"
>
Log in to Printify
</a>
(or <a
href="https://printify.com/app/auth/register"
target="_blank"
rel="noopener"
class="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>
<% end %>
<.form for={@form} id="provider-form" phx-change="validate" phx-submit="save">
<input type="hidden" name="provider_connection[provider_type]" value="printify" />
<.input
field={@form[:api_key]}
type="password"
label="Printify connection key"
placeholder={
if @live_action == :edit,
do: "Leave blank to keep current key",
else: "Paste your key here"
}
autocomplete="off"
/>
<div class="flex items-center gap-3 mb-6">
<button
type="button"
class="btn btn-outline btn-sm"
phx-click="test_connection"
disabled={@testing}
>
<.icon
name={if @testing, do: "hero-arrow-path", else: "hero-signal"}
class={if @testing, do: "size-4 animate-spin", else: "size-4"}
/>
{if @testing, do: "Checking...", else: "Check connection"}
</button>
<div :if={@test_result} class="text-sm">
<%= case @test_result do %>
<% {:ok, info} -> %>
<span class="text-success flex items-center gap-1">
<.icon name="hero-check-circle" class="size-4" /> Connected to {info.shop_name}
</span>
<% {:error, reason} -> %>
<span class="text-error flex items-center gap-1">
<.icon name="hero-x-circle" class="size-4" />
{format_error(reason)}
</span>
<% end %>
</div>
</div>
<%= if @live_action == :edit do %>
<.input field={@form[:enabled]} type="checkbox" label="Connection enabled" />
<% end %>
<div class="flex gap-2 mt-6">
<.button type="submit" disabled={@testing}>
{if @live_action == :new, do: "Connect to Printify", else: "Save changes"}
</.button>
<.link navigate={~p"/admin/providers"} class="btn btn-ghost">
Cancel
</.link>
</div>
</.form>
</div>
</Layouts.app>

View File

@@ -0,0 +1,98 @@
defmodule SimpleshopThemeWeb.ProviderLive.Index do
use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Products
alias SimpleshopTheme.Products.ProviderConnection
@impl true
def mount(_params, _session, socket) do
connections = Products.list_provider_connections()
{:ok,
socket
|> assign(:page_title, "Provider connections")
|> stream(:connections, connections)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
connection = Products.get_provider_connection!(id)
{:ok, _} = Products.delete_provider_connection(connection)
{:noreply,
socket
|> stream_delete(:connections, connection)
|> put_flash(:info, "Provider connection deleted")}
end
@impl true
def handle_event("sync", %{"id" => id}, socket) do
connection = Products.get_provider_connection!(id)
case Products.enqueue_sync(connection) do
{:ok, _job} ->
# Update the connection status in the stream
updated = %{connection | sync_status: "syncing"}
{:noreply,
socket
|> stream_insert(:connections, updated)
|> put_flash(:info, "Sync started for #{connection.name}")}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Failed to start sync")}
end
end
# Function components for the template
attr :status, :string, required: true
attr :enabled, :boolean, required: true
defp status_indicator(assigns) do
~H"""
<span class={[
"inline-flex size-3 rounded-full",
cond do
not @enabled -> "bg-base-content/30"
@status == "syncing" -> "bg-warning animate-pulse"
@status == "completed" -> "bg-success"
@status == "failed" -> "bg-error"
true -> "bg-base-content/30"
end
]} />
"""
end
attr :connection, ProviderConnection, required: true
defp connection_info(assigns) do
product_count = Products.count_products_for_connection(assigns.connection.id)
assigns = assign(assigns, :product_count, product_count)
~H"""
<span>
<.icon name="hero-cube" class="size-4 inline" />
{@product_count} {if @product_count == 1, do: "product", else: "products"}
</span>
<span :if={@connection.last_synced_at}>
<.icon name="hero-clock" class="size-4 inline" />
Last synced {format_relative_time(@connection.last_synced_at)}
</span>
<span :if={!@connection.last_synced_at} class="text-warning">
<.icon name="hero-exclamation-triangle" class="size-4 inline" /> Never synced
</span>
"""
end
defp format_relative_time(datetime) do
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
cond do
diff < 60 -> "just now"
diff < 3600 -> "#{div(diff, 60)} min ago"
diff < 86400 -> "#{div(diff, 3600)} hours ago"
true -> "#{div(diff, 86400)} days ago"
end
end
end

View File

@@ -0,0 +1,81 @@
<Layouts.app flash={@flash}>
<.header>
Providers
<:actions>
<.button navigate={~p"/admin/providers/new"}>
<.icon name="hero-plus" class="size-4 mr-1" /> Connect Printify
</.button>
</:actions>
</.header>
<div id="connections" phx-update="stream" class="mt-6 space-y-4">
<div class="hidden only:block text-center py-12">
<.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>
<p class="mt-2 text-base-content/60 max-w-md mx-auto">
Printify handles printing and shipping for you. Connect your account
to import your products and start selling.
</p>
<.button navigate={~p"/admin/providers/new"} class="mt-6">
Connect to Printify
</.button>
</div>
<div
:for={{dom_id, connection} <- @streams.connections}
id={dom_id}
class="card bg-base-100 shadow-sm border border-base-200"
>
<div class="card-body">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<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)}
</h3>
</div>
<p class="text-base-content/70 mt-1">{connection.name}</p>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-sm text-base-content/60">
<.connection_info connection={connection} />
</div>
</div>
<div class="flex items-center gap-2">
<.link
navigate={~p"/admin/providers/#{connection.id}/edit"}
class="btn btn-ghost btn-sm"
>
Settings
</.link>
<button
phx-click="delete"
phx-value-id={connection.id}
data-confirm="Disconnect from Printify? Your synced products will remain in your shop."
class="btn btn-ghost btn-sm text-error"
>
Disconnect
</button>
</div>
</div>
<div class="card-actions justify-end mt-4 pt-4 border-t border-base-200">
<button
phx-click="sync"
phx-value-id={connection.id}
disabled={connection.sync_status == "syncing"}
class="btn btn-outline btn-sm"
>
<.icon
name="hero-arrow-path"
class={
if connection.sync_status == "syncing", do: "size-4 animate-spin", else: "size-4"
}
/>
{if connection.sync_status == "syncing", do: "Syncing...", else: "Sync products"}
</button>
</div>
</div>
</div>
</div>
</Layouts.app>

View File

@@ -89,6 +89,9 @@ defmodule SimpleshopThemeWeb.Router do
live "/users/settings", UserLive.Settings, :edit
live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email
live "/admin/theme", ThemeLive.Index, :index
live "/admin/providers", ProviderLive.Index, :index
live "/admin/providers/new", ProviderLive.Form, :new
live "/admin/providers/:id/edit", ProviderLive.Form, :edit
end
post "/users/update-password", UserSessionController, :update_password