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:
@@ -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
|
||||
|
||||
134
lib/simpleshop_theme_web/live/provider_live/form.ex
Normal file
134
lib/simpleshop_theme_web/live/provider_live/form.ex
Normal 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
|
||||
104
lib/simpleshop_theme_web/live/provider_live/form.html.heex
Normal file
104
lib/simpleshop_theme_web/live/provider_live/form.html.heex
Normal 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>
|
||||
98
lib/simpleshop_theme_web/live/provider_live/index.ex
Normal file
98
lib/simpleshop_theme_web/live/provider_live/index.ex
Normal 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
|
||||
81
lib/simpleshop_theme_web/live/provider_live/index.html.heex
Normal file
81
lib/simpleshop_theme_web/live/provider_live/index.html.heex
Normal 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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user