berrypod/lib/simpleshop_theme/providers/printify.ex
jamey 880e7a2888 feat: add dynamic variant selector with color swatches
- Fix Printify options parsing (Color/Size were swapped)
- Add extract_option_types/1 for frontend display with hex colors
- Filter option types to only published variants (not full catalog)
- Track selected variant in LiveView with price updates
- Color swatches for color-type options, text buttons for size
- Disable unavailable combinations
- Add startup recovery for stale sync status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 22:17:48 +00:00

407 lines
12 KiB
Elixir

defmodule SimpleshopTheme.Providers.Printify do
@moduledoc """
Printify provider implementation.
Handles product sync and order submission for Printify.
"""
@behaviour SimpleshopTheme.Providers.Provider
alias SimpleshopTheme.Clients.Printify, as: Client
alias SimpleshopTheme.Products.ProviderConnection
@impl true
def provider_type, do: "printify"
@impl true
def test_connection(%ProviderConnection{} = conn) do
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
:ok <- set_api_key(api_key),
{:ok, shops} <- Client.get_shops() do
shop = List.first(shops)
{:ok,
%{
shop_id: shop["id"],
shop_name: shop["title"],
shop_count: length(shops)
}}
else
nil -> {:error, :no_api_key}
{:error, _} = error -> error
end
end
@impl true
def fetch_products(%ProviderConnection{config: config} = conn) do
shop_id = config["shop_id"]
if is_nil(shop_id) do
{:error, :no_shop_id}
else
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
:ok <- set_api_key(api_key),
{:ok, products} <- fetch_all_products(shop_id) do
{:ok, products}
else
nil -> {:error, :no_api_key}
{:error, _} = error -> error
end
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"]
if is_nil(shop_id) do
{:error, :no_shop_id}
else
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
:ok <- set_api_key(api_key),
order_data <- build_order_payload(order),
{:ok, response} <- Client.create_order(shop_id, order_data) do
{:ok, %{provider_order_id: response["id"]}}
else
nil -> {:error, :no_api_key}
{:error, _} = error -> error
end
end
end
@impl true
def get_order_status(%ProviderConnection{config: config} = conn, provider_order_id) do
shop_id = config["shop_id"]
if is_nil(shop_id) do
{:error, :no_shop_id}
else
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
:ok <- set_api_key(api_key),
{:ok, response} <- Client.get_order(shop_id, provider_order_id) do
{:ok, normalize_order_status(response)}
else
nil -> {:error, :no_api_key}
{:error, _} = error -> error
end
end
end
# =============================================================================
# Webhook Registration
# =============================================================================
@webhook_events ["product:updated", "product:deleted", "product:publish:started"]
@doc """
Registers webhooks for product events with Printify.
Returns {:ok, results} or {:error, reason}.
"""
def register_webhooks(%ProviderConnection{config: config} = conn, webhook_url) do
shop_id = config["shop_id"]
secret = config["webhook_secret"]
cond do
is_nil(shop_id) ->
{:error, :no_shop_id}
is_nil(secret) or secret == "" ->
{:error, :no_webhook_secret}
true ->
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
:ok <- set_api_key(api_key) do
results =
Enum.map(@webhook_events, fn event ->
case Client.create_webhook(shop_id, webhook_url, event, secret) do
{:ok, response} -> {:ok, event, response}
{:error, reason} -> {:error, event, reason}
end
end)
{:ok, results}
else
nil -> {:error, :no_api_key}
end
end
end
@doc """
Lists registered webhooks for the shop.
"""
def list_webhooks(%ProviderConnection{config: config} = conn) do
shop_id = config["shop_id"]
if is_nil(shop_id) do
{:error, :no_shop_id}
else
with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
:ok <- set_api_key(api_key),
{:ok, webhooks} <- Client.list_webhooks(shop_id) do
{:ok, webhooks}
else
nil -> {:error, :no_api_key}
{:error, _} = error -> error
end
end
end
# =============================================================================
# Option Types Extraction (for frontend)
# =============================================================================
@doc """
Extracts option types from Printify provider_data for frontend display.
Returns a list of option type maps with normalized names, types, and values
including hex color codes for color-type options.
## Examples
iex> extract_option_types(%{"options" => [
...> %{"name" => "Colors", "type" => "color", "values" => [
...> %{"id" => 1, "title" => "Black", "colors" => ["#000000"]}
...> ]}
...> ]})
[%{name: "Color", type: :color, values: [%{id: 1, title: "Black", hex: "#000000"}]}]
"""
def extract_option_types(%{"options" => options}) when is_list(options) do
Enum.map(options, fn opt ->
%{
name: singularize_option_name(opt["name"]),
type: option_type_atom(opt["type"]),
values: extract_option_values(opt)
}
end)
end
def extract_option_types(_), do: []
defp option_type_atom("color"), do: :color
defp option_type_atom("size"), do: :size
defp option_type_atom(_), do: :other
defp extract_option_values(%{"values" => values, "type" => "color"}) do
Enum.map(values, fn val ->
%{
id: val["id"],
title: val["title"],
hex: List.first(val["colors"])
}
end)
end
defp extract_option_values(%{"values" => values}) do
Enum.map(values, fn val ->
%{id: val["id"], title: val["title"]}
end)
end
# =============================================================================
# Data Normalization
# =============================================================================
defp normalize_product(raw) do
options = raw["options"] || []
%{
provider_product_id: to_string(raw["id"]),
title: raw["title"],
description: raw["description"],
category: extract_category(raw),
images: normalize_images(raw["images"] || []),
variants: normalize_variants(raw["variants"] || [], options),
provider_data: %{
blueprint_id: raw["blueprint_id"],
print_provider_id: raw["print_provider_id"],
tags: raw["tags"] || [],
options: options,
raw: raw
}
}
end
defp normalize_images(images) do
images
|> Enum.with_index()
|> Enum.map(fn {img, index} ->
# Printify returns position as a label string (e.g., "front", "back")
# We use the index as the numeric position instead
%{
src: img["src"],
position: index,
alt: img["position"]
}
end)
end
defp normalize_variants(variants, options) do
option_names = extract_option_names(options)
Enum.map(variants, fn var ->
%{
provider_variant_id: to_string(var["id"]),
title: var["title"],
sku: var["sku"],
price: var["price"],
cost: var["cost"],
options: normalize_variant_options(var, option_names),
is_enabled: var["is_enabled"] == true,
is_available: var["is_available"] == true
}
end)
end
# Extract option names from product options, singularizing common plurals
defp extract_option_names(options) do
Enum.map(options, fn opt ->
singularize_option_name(opt["name"])
end)
end
defp singularize_option_name("Colors"), do: "Color"
defp singularize_option_name("Sizes"), do: "Size"
defp singularize_option_name(name), do: name
defp normalize_variant_options(variant, option_names) do
# Build human-readable map from variant title
# Title format matches product options order: "Navy / S" for [Colors, Sizes]
title = variant["title"] || ""
parts = String.split(title, " / ")
parts
|> Enum.with_index()
|> Enum.reduce(%{}, fn {value, index}, acc ->
key = Enum.at(option_names, index) || "Option #{index + 1}"
Map.put(acc, key, value)
end)
end
defp extract_category(raw) do
# Try to extract category from tags (case-insensitive)
tags =
(raw["tags"] || [])
|> Enum.map(&String.downcase/1)
cond do
has_tag?(tags, ~w[t-shirt tshirt shirt hoodie sweatshirt apparel clothing]) -> "Apparel"
has_tag?(tags, ~w[mug cup blanket pillow cushion homeware homewares home]) -> "Homewares"
has_tag?(tags, ~w[notebook journal stationery]) -> "Stationery"
has_tag?(tags, ~w[phone case bag tote accessories]) -> "Accessories"
has_tag?(tags, ~w[art print poster canvas wall]) -> "Art Prints"
true -> List.first(raw["tags"])
end
end
defp has_tag?(tags, keywords) do
Enum.any?(tags, fn tag ->
Enum.any?(keywords, fn keyword -> String.contains?(tag, keyword) end)
end)
end
defp normalize_order_status(raw) do
%{
status: map_order_status(raw["status"]),
provider_status: raw["status"],
tracking_number: extract_tracking(raw),
tracking_url: extract_tracking_url(raw),
shipments: raw["shipments"] || []
}
end
defp map_order_status("pending"), do: "pending"
defp map_order_status("on-hold"), do: "pending"
defp map_order_status("payment-not-received"), do: "pending"
defp map_order_status("in-production"), do: "processing"
defp map_order_status("partially-shipped"), do: "processing"
defp map_order_status("shipped"), do: "shipped"
defp map_order_status("delivered"), do: "delivered"
defp map_order_status("canceled"), do: "cancelled"
defp map_order_status(_), do: "pending"
defp extract_tracking(raw) do
case raw["shipments"] do
[shipment | _] -> shipment["tracking_number"]
_ -> nil
end
end
defp extract_tracking_url(raw) do
case raw["shipments"] do
[shipment | _] -> shipment["tracking_url"]
_ -> nil
end
end
# =============================================================================
# Order Building
# =============================================================================
defp build_order_payload(order) do
%{
external_id: order.order_number,
label: order.order_number,
line_items:
Enum.map(order.line_items, fn item ->
%{
product_id: item.product_variant.product.provider_product_id,
variant_id: String.to_integer(item.product_variant.provider_variant_id),
quantity: item.quantity
}
end),
shipping_method: 1,
address_to: %{
first_name: order.shipping_address["first_name"],
last_name: order.shipping_address["last_name"],
email: order.customer_email,
phone: order.shipping_address["phone"],
country: order.shipping_address["country"],
region: order.shipping_address["state"] || order.shipping_address["region"],
address1: order.shipping_address["address1"],
address2: order.shipping_address["address2"],
city: order.shipping_address["city"],
zip: order.shipping_address["zip"] || order.shipping_address["postal_code"]
}
}
end
# =============================================================================
# API Key Management
# =============================================================================
# Temporarily sets the API key for the request
# In a production system, this would use a connection pool or request context
defp set_api_key(api_key) do
Process.put(:printify_api_key, api_key)
:ok
end
end