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:
@@ -154,9 +154,10 @@ defmodule SimpleshopTheme.Clients.Printify do
|
||||
|
||||
@doc """
|
||||
List all products in a shop.
|
||||
Printify allows a maximum of 50 products per page.
|
||||
"""
|
||||
def list_products(shop_id, opts \\ []) do
|
||||
limit = Keyword.get(opts, :limit, 100)
|
||||
limit = Keyword.get(opts, :limit, 50)
|
||||
page = Keyword.get(opts, :page, 1)
|
||||
get("/shops/#{shop_id}/products.json?limit=#{limit}&page=#{page}")
|
||||
end
|
||||
|
||||
@@ -28,6 +28,13 @@ defmodule SimpleshopTheme.Products do
|
||||
Repo.get(ProviderConnection, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single provider connection, raising if not found.
|
||||
"""
|
||||
def get_provider_connection!(id) do
|
||||
Repo.get!(ProviderConnection, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a provider connection by type.
|
||||
"""
|
||||
@@ -72,6 +79,24 @@ defmodule SimpleshopTheme.Products do
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of products for a provider connection.
|
||||
"""
|
||||
def count_products_for_connection(nil), do: 0
|
||||
|
||||
def count_products_for_connection(connection_id) do
|
||||
from(p in Product, where: p.provider_connection_id == ^connection_id, select: count())
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Enqueues a product sync job for the given provider connection.
|
||||
Returns `{:ok, job}` or `{:error, changeset}`.
|
||||
"""
|
||||
def enqueue_sync(%ProviderConnection{} = conn) do
|
||||
SimpleshopTheme.Sync.ProductSyncWorker.enqueue(conn.id)
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Products
|
||||
# =============================================================================
|
||||
@@ -160,16 +185,19 @@ defmodule SimpleshopTheme.Products do
|
||||
def upsert_product(%ProviderConnection{id: conn_id}, attrs) do
|
||||
provider_product_id = attrs[:provider_product_id] || attrs["provider_product_id"]
|
||||
new_checksum = Product.compute_checksum(attrs[:provider_data] || attrs["provider_data"])
|
||||
attrs = Map.put(attrs, :checksum, new_checksum)
|
||||
title = attrs[:title] || attrs["title"]
|
||||
|
||||
attrs =
|
||||
attrs
|
||||
|> Map.put(:checksum, new_checksum)
|
||||
|> Map.put(:provider_connection_id, conn_id)
|
||||
|
||||
# First check by provider_product_id
|
||||
case get_product_by_provider(conn_id, provider_product_id) do
|
||||
nil ->
|
||||
attrs = Map.put(attrs, :provider_connection_id, conn_id)
|
||||
|
||||
case create_product(attrs) do
|
||||
{:ok, product} -> {:ok, product, :created}
|
||||
error -> error
|
||||
end
|
||||
# Not found by provider ID - check by slug (same title = same product)
|
||||
slug = Slug.slugify(title)
|
||||
find_by_slug_or_insert(conn_id, slug, attrs, new_checksum)
|
||||
|
||||
%Product{checksum: ^new_checksum} = product ->
|
||||
{:ok, product, :unchanged}
|
||||
@@ -182,6 +210,77 @@ defmodule SimpleshopTheme.Products do
|
||||
end
|
||||
end
|
||||
|
||||
# If product exists with same slug, update it (including new provider_product_id)
|
||||
# Otherwise insert new product
|
||||
defp find_by_slug_or_insert(conn_id, slug, attrs, new_checksum) do
|
||||
case get_product_by_slug(slug) do
|
||||
%Product{provider_connection_id: ^conn_id, checksum: ^new_checksum} = product ->
|
||||
# Same product, same checksum - just update the provider_product_id if changed
|
||||
if product.provider_product_id != attrs[:provider_product_id] do
|
||||
case update_product(product, %{provider_product_id: attrs[:provider_product_id]}) do
|
||||
{:ok, product} -> {:ok, product, :updated}
|
||||
error -> error
|
||||
end
|
||||
else
|
||||
{:ok, product, :unchanged}
|
||||
end
|
||||
|
||||
%Product{provider_connection_id: ^conn_id} = product ->
|
||||
# Same product, different checksum - full update including new provider_product_id
|
||||
case update_product(product, attrs) do
|
||||
{:ok, product} -> {:ok, product, :updated}
|
||||
error -> error
|
||||
end
|
||||
|
||||
_ ->
|
||||
# Not found or belongs to different connection - insert new
|
||||
do_insert_product(attrs)
|
||||
end
|
||||
end
|
||||
|
||||
# Insert with conflict handling for race conditions
|
||||
defp do_insert_product(attrs) do
|
||||
case create_product(attrs) do
|
||||
{:ok, product} ->
|
||||
{:ok, product, :created}
|
||||
|
||||
{:error, %Ecto.Changeset{errors: errors} = changeset} ->
|
||||
# Check if it's a unique constraint violation (race condition)
|
||||
if has_unique_constraint_error?(errors) do
|
||||
handle_insert_conflict(attrs, changeset)
|
||||
else
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_insert_conflict(attrs, changeset) do
|
||||
conn_id = attrs[:provider_connection_id]
|
||||
provider_product_id = attrs[:provider_product_id]
|
||||
new_checksum = attrs[:checksum]
|
||||
|
||||
case get_product_by_provider(conn_id, provider_product_id) do
|
||||
nil ->
|
||||
{:error, changeset}
|
||||
|
||||
%Product{checksum: ^new_checksum} = product ->
|
||||
{:ok, product, :unchanged}
|
||||
|
||||
product ->
|
||||
case update_product(product, attrs) do
|
||||
{:ok, product} -> {:ok, product, :updated}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp has_unique_constraint_error?(errors) do
|
||||
Enum.any?(errors, fn
|
||||
{_field, {_msg, [constraint: :unique, constraint_name: _]}} -> true
|
||||
_ -> false
|
||||
end)
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Product Images
|
||||
# =============================================================================
|
||||
|
||||
@@ -80,8 +80,7 @@ defmodule SimpleshopTheme.Products.Product do
|
||||
title = get_change(changeset, :title) || get_field(changeset, :title)
|
||||
|
||||
if title do
|
||||
slug = Slug.slugify(title)
|
||||
put_change(changeset, :slug, slug)
|
||||
put_change(changeset, :slug, Slug.slugify(title))
|
||||
else
|
||||
changeset
|
||||
end
|
||||
|
||||
27
lib/simpleshop_theme/providers.ex
Normal file
27
lib/simpleshop_theme/providers.ex
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule SimpleshopTheme.Providers do
|
||||
@moduledoc """
|
||||
Convenience functions for working with POD providers.
|
||||
"""
|
||||
|
||||
alias SimpleshopTheme.Products.ProviderConnection
|
||||
alias SimpleshopTheme.Providers.Provider
|
||||
|
||||
@doc """
|
||||
Tests a provider connection.
|
||||
|
||||
Returns `{:ok, info}` with provider-specific info (e.g., shop name, shop_id)
|
||||
or `{:error, reason}` if the connection fails.
|
||||
"""
|
||||
def test_connection(%ProviderConnection{} = conn) do
|
||||
case Provider.for_connection(conn) do
|
||||
{:ok, provider} -> provider.test_connection(conn)
|
||||
{:error, :not_implemented} -> {:error, :provider_not_implemented}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the provider module for a given type.
|
||||
"""
|
||||
defdelegate for_type(type), to: Provider
|
||||
end
|
||||
@@ -41,11 +41,7 @@ defmodule SimpleshopTheme.Providers.Printify do
|
||||
else
|
||||
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
|
||||
:ok <- set_api_key(api_key),
|
||||
{:ok, response} <- Client.list_products(shop_id) do
|
||||
products =
|
||||
response["data"]
|
||||
|> Enum.map(&normalize_product/1)
|
||||
|
||||
{:ok, products} <- fetch_all_products(shop_id) do
|
||||
{:ok, products}
|
||||
else
|
||||
nil -> {:error, :no_api_key}
|
||||
@@ -54,6 +50,33 @@ defmodule SimpleshopTheme.Providers.Printify do
|
||||
end
|
||||
end
|
||||
|
||||
# Fetches all products by paginating through the API
|
||||
defp fetch_all_products(shop_id) do
|
||||
fetch_products_page(shop_id, 1, [])
|
||||
end
|
||||
|
||||
defp fetch_products_page(shop_id, page, acc) do
|
||||
case Client.list_products(shop_id, page: page) do
|
||||
{:ok, response} ->
|
||||
products = Enum.map(response["data"] || [], &normalize_product/1)
|
||||
all_products = acc ++ products
|
||||
|
||||
current_page = response["current_page"] || page
|
||||
last_page = response["last_page"] || 1
|
||||
|
||||
if current_page < last_page do
|
||||
# Small delay to be nice to rate limits (600/min = 10/sec)
|
||||
Process.sleep(100)
|
||||
fetch_products_page(shop_id, page + 1, all_products)
|
||||
else
|
||||
{:ok, all_products}
|
||||
end
|
||||
|
||||
{:error, _} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def submit_order(%ProviderConnection{config: config} = conn, order) do
|
||||
shop_id = config["shop_id"]
|
||||
|
||||
@@ -59,19 +59,34 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
||||
# Private
|
||||
# =============================================================================
|
||||
|
||||
# Number of concurrent product syncs (DB operations only, not API calls)
|
||||
@max_concurrency 5
|
||||
|
||||
defp sync_products(conn) do
|
||||
Logger.info("Starting product sync for #{conn.provider_type} (#{conn.id})")
|
||||
|
||||
Products.update_sync_status(conn, "syncing")
|
||||
|
||||
try do
|
||||
do_sync_products(conn)
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Product sync crashed for #{conn.provider_type}: #{Exception.message(e)}")
|
||||
Products.update_sync_status(conn, "failed")
|
||||
{:error, :sync_crashed}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_sync_products(conn) do
|
||||
with {:ok, provider} <- Provider.for_connection(conn),
|
||||
{:ok, products} <- provider.fetch_products(conn) do
|
||||
Logger.info("Fetched #{length(products)} products from #{conn.provider_type}")
|
||||
|
||||
results = sync_all_products(conn, products)
|
||||
|
||||
created = Enum.count(results, fn {_, _, status} -> status == :created end)
|
||||
updated = Enum.count(results, fn {_, _, status} -> status == :updated end)
|
||||
unchanged = Enum.count(results, fn {_, _, status} -> status == :unchanged end)
|
||||
errors = Enum.count(results, fn result -> match?({:error, _}, result) end)
|
||||
created = Enum.count(results, &match?({:ok, _, :created}, &1))
|
||||
updated = Enum.count(results, &match?({:ok, _, :updated}, &1))
|
||||
unchanged = Enum.count(results, &match?({:ok, _, :unchanged}, &1))
|
||||
errors = Enum.count(results, &match?({:error, _}, &1))
|
||||
|
||||
Logger.info(
|
||||
"Product sync complete for #{conn.provider_type}: " <>
|
||||
@@ -89,18 +104,35 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
||||
end
|
||||
|
||||
defp sync_all_products(conn, products) do
|
||||
Enum.map(products, fn product_data ->
|
||||
case sync_product(conn, product_data) do
|
||||
{:ok, product, status} ->
|
||||
sync_product_associations(product, product_data)
|
||||
{:ok, product, status}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
products
|
||||
|> Task.async_stream(
|
||||
fn product_data -> sync_single_product(conn, product_data) end,
|
||||
max_concurrency: @max_concurrency,
|
||||
timeout: 30_000,
|
||||
on_timeout: :kill_task
|
||||
)
|
||||
|> Enum.map(fn
|
||||
{:ok, result} -> result
|
||||
{:exit, :timeout} -> {:error, :timeout}
|
||||
{:exit, reason} -> {:error, reason}
|
||||
end)
|
||||
end
|
||||
|
||||
defp sync_single_product(conn, product_data) do
|
||||
case sync_product(conn, product_data) do
|
||||
{:ok, product, status} ->
|
||||
sync_product_associations(product, product_data)
|
||||
{:ok, product, status}
|
||||
|
||||
error ->
|
||||
Logger.warning(
|
||||
"Failed to sync product #{product_data[:provider_product_id]}: #{inspect(error)}"
|
||||
)
|
||||
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_product(conn, product_data) do
|
||||
attrs = %{
|
||||
provider_product_id: product_data[:provider_product_id],
|
||||
|
||||
@@ -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