From 39e9744eb764c9f7dcf555090a913251fbc80387 Mon Sep 17 00:00:00 2001 From: James Greenwood Date: Sun, 30 Nov 2025 11:05:49 +0000 Subject: [PATCH] basic printify integration --- assets/css/app.css | 5 + config/dev.exs | 7 + lib/simpleshop/printify/client.ex | 124 +++++ lib/simpleshop/printify/oauth.ex | 92 ++++ lib/simpleshop/printify/token_store.ex | 24 + .../components/layouts/root.html.heex | 5 +- .../controllers/oauth_controller.ex | 9 + .../controllers/page_html/home.html.heex | 249 +++-------- lib/simpleshop_web/endpoint.ex | 4 + lib/simpleshop_web/live/debug_live.ex | 194 ++++++++ .../live/oauth_callback_live.ex | 78 ++++ .../live/order_confirmation_live.ex | 158 +++++++ lib/simpleshop_web/live/product_live.ex | 423 ++++++++++++++++++ lib/simpleshop_web/router.ex | 3 + mix.exs | 3 +- mix.lock | 2 + 16 files changed, 1181 insertions(+), 199 deletions(-) create mode 100644 lib/simpleshop/printify/client.ex create mode 100644 lib/simpleshop/printify/oauth.ex create mode 100644 lib/simpleshop/printify/token_store.ex create mode 100644 lib/simpleshop_web/controllers/oauth_controller.ex create mode 100644 lib/simpleshop_web/live/debug_live.ex create mode 100644 lib/simpleshop_web/live/oauth_callback_live.ex create mode 100644 lib/simpleshop_web/live/order_confirmation_live.ex create mode 100644 lib/simpleshop_web/live/product_live.ex diff --git a/assets/css/app.css b/assets/css/app.css index c19b7d2..e9234a2 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -102,4 +102,9 @@ /* Make LiveView wrapper divs transparent for layout */ [data-phx-session], [data-phx-teleported-src] { display: contents } +/* Restore pointer cursor for buttons and links */ +button, a, [role="button"] { + cursor: pointer; +} + /* This file is for your main application CSS */ diff --git a/config/dev.exs b/config/dev.exs index 78aa3a6..df9e6f2 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -87,3 +87,10 @@ config :phoenix_live_view, # Disable swoosh api client as it is only required for production adapters. config :swoosh, :api_client, false + +# Printify API configuration +# Get your Personal Access Token from: https://printify.com/app/account/api +config :simpleshop, :printify, + access_token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiIzN2Q0YmQzMDM1ZmUxMWU5YTgwM2FiN2VlYjNjY2M5NyIsImp0aSI6IjI1NGYwMjMxMWI2Y2VkNmRhZGQ0ZTg1MmY3MjBkNGNlYjg2ZjZmNWE5NWJlMzUwYWZjNzc4MWYyYjg2ZDA5YzcyMTU1MmJjMDM5YTVlNmYwIiwiaWF0IjoxNzY0NDk4MzcyLjI5MDYzNywibmJmIjoxNzY0NDk4MzcyLjI5MDYzOSwiZXhwIjoxNzk2MDM0MzcyLjI4MTkxNCwic3ViIjoiMjU1NDc5MzIiLCJzY29wZXMiOlsic2hvcHMubWFuYWdlIiwic2hvcHMucmVhZCIsImNhdGFsb2cucmVhZCIsIm9yZGVycy5yZWFkIiwib3JkZXJzLndyaXRlIiwicHJvZHVjdHMucmVhZCIsInByb2R1Y3RzLndyaXRlIiwid2ViaG9va3MucmVhZCIsIndlYmhvb2tzLndyaXRlIiwidXBsb2Fkcy5yZWFkIiwidXBsb2Fkcy53cml0ZSIsInByaW50X3Byb3ZpZGVycy5yZWFkIiwidXNlci5pbmZvIl19.lR9KxY54llZSUj6kNpCjAjdCSfkp8ddRyi0g5sKtVfoaPePFGFl6KA3LXg-uFOvCqhIk9sSEhkUE-UES5jhyKh-uqrhsHLbDZ4m_oXC97aOtbc3nA1IcO_mnlfx863qO7myQ8E33l_ulZP-N0K9JKQKxeGxFgLCr2oFpnmkeca0v4PUL9hewBgXMirUdQgjiBD7CP3kxDiYu4_9Sr6UmsNzVzPKp6X0tuhKf1nvAbAgLLf3jgaoTRCqofNm46bqqXZ9XDmCRrH0HTTY7NtLRwURGfovXQpMdHsfWmz2bn6hITqLILqtmNYsAaXRer7gSq60RyDgu726taA5eDEnUYhpYJC_b6eeAYGq8hTOZqH3AHXeKi_UwbEzsP9a7a3DLOyKiNBmpkYTqeZJdDfX6USKg6ypNDRxXu5HkefkVR2iSfkbg0UCBr-qt-_kM3lPkHbe2kaHlsuNxT1WJfHQ6f9O8GRwJKvcrc7icLqMe9EwsBFzRN9PmKpLch2ofexKR0DaBqb6hrOzZvm8BqfSSMCQ3p5dLX8J7ygGDHpDxvdtc2MPxu57bjPOmcCLqQNd42KjQHmyA3KM9LK1WL3CfW1HHy7-0zfwyclAvEqX0oehqx5w40FbF1c38R9MjEMClOJQefljGXmyBP_e5TjtRcPwBFN182KRwevSRD0PRb68", + shop_id: "25499856", + base_url: "https://api.printify.com/v1" diff --git a/lib/simpleshop/printify/client.ex b/lib/simpleshop/printify/client.ex new file mode 100644 index 0000000..2cc4131 --- /dev/null +++ b/lib/simpleshop/printify/client.ex @@ -0,0 +1,124 @@ +defmodule Simpleshop.Printify.Client do + require Logger + alias Simpleshop.Printify.TokenStore + + @base_url "https://api.printify.com/v1" + @user_agent "SimpleshopMVP" + + def get_products do + config = Application.get_env(:simpleshop, :printify) + shop_id = Keyword.fetch!(config, :shop_id) + url = "#{@base_url}/shops/#{shop_id}/products.json" + + with_token(fn token -> + case Req.get(url, headers: headers(token)) do + {:ok, %{status: 200, body: response}} -> + products = Map.get(response, "data") || response + Logger.info("Successfully fetched #{length(products)} products") + {:ok, products} + + {:ok, %{status: status, body: body}} -> + Logger.error("Failed to fetch products: #{status} - #{inspect(body)}") + {:error, "Failed to fetch products: #{status}"} + + {:error, error} -> + Logger.error("HTTP error fetching products: #{inspect(error)}") + {:error, "Network error: #{inspect(error)}"} + end + end) + end + + def get_product(product_id) do + config = Application.get_env(:simpleshop, :printify) + shop_id = Keyword.fetch!(config, :shop_id) + url = "#{@base_url}/shops/#{shop_id}/products/#{product_id}.json" + + with_token(fn token -> + case Req.get(url, headers: headers(token)) do + {:ok, %{status: 200, body: product}} -> + Logger.info("Successfully fetched product #{product_id}") + {:ok, product} + + {:ok, %{status: status, body: body}} -> + Logger.error("Failed to fetch product: #{status} - #{inspect(body)}") + {:error, "Failed to fetch product: #{status}"} + + {:error, error} -> + Logger.error("HTTP error fetching product: #{inspect(error)}") + {:error, "Network error: #{inspect(error)}"} + end + end) + end + + def create_order(product_id, variant_id, quantity \\ 1) do + config = Application.get_env(:simpleshop, :printify) + shop_id = Keyword.fetch!(config, :shop_id) + url = "#{@base_url}/shops/#{shop_id}/orders.json" + + order_data = %{ + external_id: "simpleshop-#{System.unique_integer([:positive])}", + line_items: [ + %{ + product_id: product_id, + variant_id: variant_id, + quantity: quantity + } + ], + shipping_method: 1, + address_to: test_shipping_address() + } + + with_token(fn token -> + case Req.post(url, headers: headers(token), json: order_data) do + {:ok, %{status: status, body: response}} when status in 200..299 -> + Logger.info("Successfully created order: #{inspect(response)}") + {:ok, response} + + {:ok, %{status: status, body: body}} -> + Logger.error("Failed to create order: #{status} - #{inspect(body)}") + {:error, "Failed to create order: #{status} - #{inspect(body)}"} + + {:error, error} -> + Logger.error("HTTP error creating order: #{inspect(error)}") + {:error, "Network error: #{inspect(error)}"} + end + end) + end + + defp with_token(fun) do + case TokenStore.get_access_token() do + {:ok, token} -> + fun.(token) + + {:error, :not_configured} -> + Logger.error("Printify Personal Access Token not configured in config/dev.exs") + {:error, :not_configured} + + {:error, reason} -> + Logger.error("Failed to get access token: #{inspect(reason)}") + {:error, :authentication_required} + end + end + + defp headers(token) do + [ + {"Authorization", "Bearer #{token}"}, + {"User-Agent", @user_agent}, + {"Content-Type", "application/json;charset=utf-8"} + ] + end + + defp test_shipping_address do + %{ + first_name: "John", + last_name: "Doe", + email: "test@example.com", + phone: "555-0123", + country: "US", + region: "CA", + address1: "123 Test St", + city: "San Francisco", + zip: "94102" + } + end +end diff --git a/lib/simpleshop/printify/oauth.ex b/lib/simpleshop/printify/oauth.ex new file mode 100644 index 0000000..5d1199d --- /dev/null +++ b/lib/simpleshop/printify/oauth.ex @@ -0,0 +1,92 @@ +defmodule Simpleshop.Printify.OAuth do + require Logger + alias Simpleshop.Printify.TokenStore + + @printify_auth_url "https://printify.com/app/authorize" + @printify_token_url "https://api.printify.com/v1/app/oauth/tokens" + @printify_refresh_url "https://api.printify.com/v1/app/oauth/tokens/refresh" + + def authorization_url do + config = Application.get_env(:simpleshop, :printify) + app_id = Keyword.fetch!(config, :app_id) + redirect_uri = Keyword.fetch!(config, :redirect_uri) + + query = + URI.encode_query(%{ + app_id: app_id, + accept_url: redirect_uri, + decline_url: redirect_uri <> "?error=access_denied" + }) + + "#{@printify_auth_url}?#{query}" + end + + def exchange_code(code) do + config = Application.get_env(:simpleshop, :printify) + app_id = Keyword.fetch!(config, :app_id) + + body = %{ + app_id: app_id, + code: code + } + + case Req.post(@printify_token_url, json: body) do + {:ok, %{status: 200, body: response}} -> + Logger.info("Successfully exchanged OAuth code for tokens") + store_token_response(response) + + {:ok, %{status: status, body: body}} -> + Logger.error("Failed to exchange OAuth code: #{status} - #{inspect(body)}") + {:error, "Failed to get access token: #{inspect(body)}"} + + {:error, error} -> + Logger.error("HTTP error during token exchange: #{inspect(error)}") + {:error, "Network error: #{inspect(error)}"} + end + end + + def refresh_access_token do + case TokenStore.get_refresh_token() do + {:ok, refresh_token} -> + config = Application.get_env(:simpleshop, :printify) + app_id = Keyword.fetch!(config, :app_id) + + body = %{ + app_id: app_id, + refresh_token: refresh_token + } + + case Req.post(@printify_refresh_url, json: body) do + {:ok, %{status: 200, body: response}} -> + Logger.info("Successfully refreshed access token") + store_token_response(response) + + {:ok, %{status: status, body: body}} -> + Logger.error("Failed to refresh access token: #{status} - #{inspect(body)}") + {:error, "Failed to refresh token: #{inspect(body)}"} + + {:error, error} -> + Logger.error("HTTP error during token refresh: #{inspect(error)}") + {:error, "Network error: #{inspect(error)}"} + end + + {:error, :not_found} -> + Logger.error("No refresh token found") + {:error, :no_refresh_token} + end + end + + defp store_token_response(response) do + access_token = Map.get(response, "access_token") || Map.get(response, :access_token) + refresh_token = Map.get(response, "refresh_token") || Map.get(response, :refresh_token) + expires_in = Map.get(response, "expires_in") || Map.get(response, :expires_in) || 21_600 + + if access_token && refresh_token do + TokenStore.store_tokens(access_token, refresh_token, expires_in) + {:ok, access_token} + else + Logger.error("Missing tokens in response: #{inspect(response)}") + {:error, "Invalid token response"} + end + end +end diff --git a/lib/simpleshop/printify/token_store.ex b/lib/simpleshop/printify/token_store.ex new file mode 100644 index 0000000..83ac449 --- /dev/null +++ b/lib/simpleshop/printify/token_store.ex @@ -0,0 +1,24 @@ +defmodule Simpleshop.Printify.TokenStore do + @moduledoc """ + Simple module to retrieve the Printify Personal Access Token from config. + No GenServer or ETS needed - just reads from application config. + """ + + def get_access_token do + config = Application.get_env(:simpleshop, :printify) + + case Keyword.get(config, :access_token) do + nil -> {:error, :not_configured} + "" -> {:error, :not_configured} + "your_personal_access_token_here" -> {:error, :not_configured} + token -> {:ok, token} + end + end + + def has_tokens? do + case get_access_token() do + {:ok, _} -> true + _ -> false + end + end +end diff --git a/lib/simpleshop_web/components/layouts/root.html.heex b/lib/simpleshop_web/components/layouts/root.html.heex index 626656b..2c05dfd 100644 --- a/lib/simpleshop_web/components/layouts/root.html.heex +++ b/lib/simpleshop_web/components/layouts/root.html.heex @@ -15,7 +15,8 @@ const setTheme = (theme) => { if (theme === "system") { localStorage.removeItem("phx:theme"); - document.documentElement.removeAttribute("data-theme"); + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + document.documentElement.setAttribute("data-theme", prefersDark ? "dark" : "light"); } else { localStorage.setItem("phx:theme", theme); document.documentElement.setAttribute("data-theme", theme); @@ -25,7 +26,7 @@ setTheme(localStorage.getItem("phx:theme") || "system"); } window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system")); - + window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme)); })(); diff --git a/lib/simpleshop_web/controllers/oauth_controller.ex b/lib/simpleshop_web/controllers/oauth_controller.ex new file mode 100644 index 0000000..ad6e043 --- /dev/null +++ b/lib/simpleshop_web/controllers/oauth_controller.ex @@ -0,0 +1,9 @@ +defmodule SimpleshopWeb.OAuthController do + use SimpleshopWeb, :controller + alias Simpleshop.Printify.OAuth + + def authorize(conn, _params) do + auth_url = OAuth.authorization_url() + redirect(conn, external: auth_url) + end +end diff --git a/lib/simpleshop_web/controllers/page_html/home.html.heex b/lib/simpleshop_web/controllers/page_html/home.html.heex index b107fd0..7e70b6c 100644 --- a/lib/simpleshop_web/controllers/page_html/home.html.heex +++ b/lib/simpleshop_web/controllers/page_html/home.html.heex @@ -1,202 +1,59 @@ - - -
-
- -
-

- Phoenix Framework - - v{Application.spec(:phoenix, :vsn)} - +
+
+
+

+ Simpleshop

- -
+

+ Printify Integration MVP +

+

+ Browse products and create test orders with your Printify catalog +

-

- Peace of mind from prototype to production. -

-

- Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. -

-
-
- - + + View Products + + +
+

Features:

+
    +
  • + + + + Browse your Printify product catalog +
  • +
  • + + + + View product details with variants and pricing +
  • +
  • + + + + Create test orders in your Printify shop +
  • +
+ +
+

+ Setup Required: Add your Personal Access Token to config/dev.exs +

+

+ Get your token at: printify.com/app/account/api +

+
+ +

+ This is a proof-of-concept integration using Personal Access Token +

diff --git a/lib/simpleshop_web/endpoint.ex b/lib/simpleshop_web/endpoint.ex index 7350bf2..6337c14 100644 --- a/lib/simpleshop_web/endpoint.ex +++ b/lib/simpleshop_web/endpoint.ex @@ -27,6 +27,10 @@ defmodule SimpleshopWeb.Endpoint do only: SimpleshopWeb.static_paths(), raise_on_missing_only: code_reloading? + if Code.ensure_loaded?(Tidewave) do + plug Tidewave + end + # Code reloading can be explicitly enabled under the # :code_reloader configuration of your endpoint. if code_reloading? do diff --git a/lib/simpleshop_web/live/debug_live.ex b/lib/simpleshop_web/live/debug_live.ex new file mode 100644 index 0000000..c07b91f --- /dev/null +++ b/lib/simpleshop_web/live/debug_live.ex @@ -0,0 +1,194 @@ +defmodule SimpleshopWeb.DebugLive do + use SimpleshopWeb, :live_view + require Logger + + @impl true + def mount(_params, _session, socket) do + send(self(), :fetch_shops) + + {:ok, + socket + |> assign(:loading, true) + |> assign(:shops, []) + |> assign(:error, nil) + |> assign(:response_details, nil)} + end + + @impl true + def handle_info(:fetch_shops, socket) do + config = Application.get_env(:simpleshop, :printify) + token = Keyword.get(config, :access_token) + + if token && token != "your_personal_access_token_here" do + url = "https://api.printify.com/v1/shops.json" + + # Try without the Content-Type header - some APIs don't like it for GET requests + headers = [ + {"Authorization", "Bearer #{token}"}, + {"User-Agent", "SimpleshopMVP"} + ] + + Logger.info("Making request to: #{url}") + Logger.info("Token (first 20 chars): #{String.slice(token, 0, 20)}...") + + case Req.get(url, headers: headers) do + {:ok, %{status: 200, body: shops}} -> + Logger.info("Successfully fetched #{length(shops)} shops") + + {:noreply, + socket + |> assign(:loading, false) + |> assign(:shops, shops) + |> assign(:error, nil) + |> assign(:response_details, nil)} + + {:ok, %{status: status, body: body, headers: resp_headers}} -> + Logger.error("Failed to fetch shops: #{status}") + Logger.error("Response body: #{inspect(body)}") + Logger.error("Response headers: #{inspect(resp_headers)}") + + error_msg = case body do + %{"errors" => errors} -> "API Error: #{inspect(errors)}" + %{"message" => message} -> "API Error: #{message}" + _ -> inspect(body) + end + + {:noreply, + socket + |> assign(:loading, false) + |> assign(:error, "HTTP #{status}: Authentication failed") + |> assign(:response_details, %{ + status: status, + body: body, + error_msg: error_msg + })} + + {:error, error} -> + Logger.error("HTTP error fetching shops: #{inspect(error)}") + + {:noreply, + socket + |> assign(:loading, false) + |> assign(:error, "Network error: #{inspect(error)}") + |> assign(:response_details, nil)} + end + else + {:noreply, + socket + |> assign(:loading, false) + |> assign(:error, "Access token not configured. Please add it to config/dev.exs") + |> assign(:response_details, nil)} + end + end + + @impl true + def render(assigns) do + ~H""" +
+

Printify Shop Information

+ + <%= if @loading do %> +
+
+

Fetching your shop information...

+
+ <% else %> + <%= if @error do %> +
+

Error

+

<%= @error %>

+ + <%= if @response_details do %> +
+

Debug Information:

+
+

Status Code: <%= @response_details.status %>

+

Error Message: <%= @response_details.error_msg %>

+
+ Full Response Body +
<%= Jason.encode!(@response_details.body, pretty: true) %>
+
+
+
+ +
+

Troubleshooting Tips:

+
    +
  • Make sure you copied the FULL token (it's very long)
  • +
  • Check that there are no extra spaces before or after the token
  • +
  • The token should start with "eyJ"
  • +
  • Make sure the token hasn't expired
  • +
  • Try creating a new Personal Access Token in Printify
  • +
+
+ <% end %> +
+ <% else %> + <%= if @shops == [] do %> +
+

No shops found for this account.

+
+ <% else %> +
+ <%= for shop <- @shops do %> +
+
+

<%= shop["title"] %>

+
+ +
+
+

Shop ID (Use this!)

+

+ <%= shop["id"] %> +

+
+ +
+
+

Sales Channel

+

<%= shop["sales_channel"] || "N/A" %>

+
+
+
+ +
+

+ Next step: Copy the Shop ID above and paste it into + config/dev.exs +

+
shop_id: "<%= shop["id"] %>",
+
+ +
+ + View full shop details (JSON) + +
<%= Jason.encode!(shop, pretty: true) %>
+
+
+ <% end %> +
+ <% end %> + <% end %> + +
+ <.link + navigate={~p"/"} + class="inline-block bg-gray-600 text-white px-6 py-2 rounded-lg hover:bg-gray-700" + > + ← Back to Home + + + <.link + navigate={~p"/products"} + class="inline-block bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700" + > + View Products → + +
+ <% end %> +
+ """ + end +end diff --git a/lib/simpleshop_web/live/oauth_callback_live.ex b/lib/simpleshop_web/live/oauth_callback_live.ex new file mode 100644 index 0000000..f0d7322 --- /dev/null +++ b/lib/simpleshop_web/live/oauth_callback_live.ex @@ -0,0 +1,78 @@ +defmodule SimpleshopWeb.OAuthCallbackLive do + use SimpleshopWeb, :live_view + require Logger + alias Simpleshop.Printify.OAuth + + @impl true + def mount(params, _session, socket) do + cond do + Map.has_key?(params, "code") -> + code = params["code"] + Logger.info("Received OAuth callback with code") + + case OAuth.exchange_code(code) do + {:ok, _token} -> + {:ok, + socket + |> put_flash(:info, "Successfully connected to Printify!") + |> push_navigate(to: ~p"/products")} + + {:error, reason} -> + Logger.error("OAuth token exchange failed: #{inspect(reason)}") + + {:ok, + socket + |> put_flash(:error, "Failed to connect to Printify. Please try again.") + |> assign(:error, inspect(reason))} + end + + Map.has_key?(params, "error") -> + {:ok, + socket + |> put_flash(:error, "Authorization declined. Please try again.") + |> assign(:error, "User declined authorization")} + + true -> + {:ok, + socket + |> put_flash(:error, "Invalid callback. Missing authorization code.") + |> assign(:error, "No code parameter")} + end + end + + @impl true + def render(assigns) do + ~H""" +
+
+

Processing Printify Authorization...

+ + <%= if assigns[:error] do %> +
+

Authentication Failed

+

+ We couldn't complete the connection to Printify. +

+ <.link + navigate={~p"/"} + class="inline-block bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700" + > + Return Home + +
+ +
+ + Technical Details (for debugging) + +
<%= @error %>
+
+ <% else %> +
+

Redirecting to products...

+ <% end %> +
+
+ """ + end +end diff --git a/lib/simpleshop_web/live/order_confirmation_live.ex b/lib/simpleshop_web/live/order_confirmation_live.ex new file mode 100644 index 0000000..579d406 --- /dev/null +++ b/lib/simpleshop_web/live/order_confirmation_live.ex @@ -0,0 +1,158 @@ +defmodule SimpleshopWeb.OrderConfirmationLive do + use SimpleshopWeb, :live_view + require Logger + + @impl true + def mount(%{"id" => order_id} = params, _session, socket) do + order_data = + case params["data"] do + nil -> + %{"id" => order_id} + + encoded_data -> + case Base.url_decode64(encoded_data) do + {:ok, json} -> + case Jason.decode(json) do + {:ok, data} -> data + {:error, _} -> %{"id" => order_id} + end + + {:error, _} -> + %{"id" => order_id} + end + end + + {:ok, + socket + |> assign(:order_id, order_id) + |> assign(:order_data, order_data)} + end + + @impl true + def render(assigns) do + ~H""" +
+
+
+ + + +
+ +

Order Created Successfully!

+

Your test order has been submitted to Printify

+
+ +
+

Order Summary

+ +
+
+ Order ID: + <%= @order_id %> +
+ + <%= if @order_data["status"] do %> +
+ Status: + + <%= @order_data["status"] %> + +
+ <% end %> + + <%= if @order_data["created_at"] do %> +
+ Created: + <%= @order_data["created_at"] %> +
+ <% end %> + + <%= if line_items = @order_data["line_items"] do %> +
+ Line Items: + <%= for item <- line_items do %> +
+ Product: <%= item["product_id"] %> + Variant: <%= item["variant_id"] %> + Qty: <%= item["quantity"] %> +
+ <% end %> +
+ <% end %> + + <%= if address = @order_data["address_to"] do %> +
+ Shipping Address: +
+

<%= address["first_name"] %> <%= address["last_name"] %>

+

<%= address["address1"] %>

+ <%= if address["address2"] do %> +

<%= address["address2"] %>

+ <% end %> +

+ <%= address["city"] %>, <%= address["region"] %> <%= address["zip"] %> +

+

<%= address["country"] %>

+ <%= if address["email"] do %> +

Email: <%= address["email"] %>

+ <% end %> + <%= if address["phone"] do %> +

Phone: <%= address["phone"] %>

+ <% end %> +
+
+ <% end %> +
+
+ +
+
+ + Full API Response (for debugging) + +
<%= Jason.encode!(@order_data, pretty: true) %>
+
+
+ +
+

+ Next steps: + Check your Printify dashboard to see this order and manage its fulfillment. +

+
+ +
+ <.link + navigate={~p"/products"} + class="bg-blue-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors" + > + Order Another Product + + + + View in Printify Dashboard → + +
+ +

+ This was a test order with a hardcoded shipping address. +

+
+ """ + end +end diff --git a/lib/simpleshop_web/live/product_live.ex b/lib/simpleshop_web/live/product_live.ex new file mode 100644 index 0000000..8563542 --- /dev/null +++ b/lib/simpleshop_web/live/product_live.ex @@ -0,0 +1,423 @@ +defmodule SimpleshopWeb.ProductLive do + use SimpleshopWeb, :live_view + require Logger + alias Simpleshop.Printify.{Client, TokenStore} + + @impl true + def mount(_params, _session, socket) do + if TokenStore.has_tokens?() do + send(self(), :load_products) + + {:ok, + socket + |> assign(:loading, true) + |> assign(:products, []) + |> assign(:selected_product, nil) + |> assign(:selected_variants, %{}) + |> assign(:error, nil)} + else + {:ok, + socket + |> assign(:loading, false) + |> assign(:products, []) + |> assign(:selected_product, nil) + |> assign(:selected_variants, %{}) + |> assign(:error, :not_configured)} + end + end + + @impl true + def handle_info(:load_products, socket) do + case Client.get_products() do + {:ok, products} -> + {:noreply, + socket + |> assign(:loading, false) + |> assign(:products, products) + |> assign(:error, nil)} + + {:error, :not_configured} -> + {:noreply, + socket + |> assign(:loading, false) + |> assign(:error, :not_configured)} + + {:error, :authentication_required} -> + {:noreply, + socket + |> assign(:loading, false) + |> assign(:error, :not_configured)} + + {:error, reason} -> + Logger.error("Failed to load products: #{inspect(reason)}") + + {:noreply, + socket + |> assign(:loading, false) + |> assign(:error, "Failed to load products. Please check your configuration and try again.")} + end + end + + @impl true + def handle_event("select_product", %{"id" => product_id}, socket) do + case Client.get_product(product_id) do + {:ok, product} -> + variant_options = parse_variant_options(product) + initial_selections = initialize_variant_selections(variant_options) + + {:noreply, + socket + |> assign(:selected_product, product) + |> assign(:variant_options, variant_options) + |> assign(:selected_variants, initial_selections) + |> assign(:error, nil)} + + {:error, reason} -> + Logger.error("Failed to load product: #{inspect(reason)}") + + {:noreply, + socket + |> assign(:error, "Failed to load product details. Please try again.")} + end + end + + @impl true + def handle_event("back_to_list", _params, socket) do + {:noreply, + socket + |> assign(:selected_product, nil) + |> assign(:selected_variants, %{}) + |> assign(:variant_options, nil)} + end + + @impl true + def handle_event("update_variant", %{"option" => option_name, "value" => value}, socket) do + selected_variants = Map.put(socket.assigns.selected_variants, option_name, value) + + {:noreply, assign(socket, :selected_variants, selected_variants)} + end + + @impl true + def handle_event("checkout", _params, socket) do + product = socket.assigns.selected_product + selected_variant = find_matching_variant(product, socket.assigns.selected_variants) + + case selected_variant do + nil -> + {:noreply, + socket + |> put_flash(:error, "Please select all variant options") + |> assign(:error, "Please select all variant options")} + + variant -> + product_id = product["id"] + variant_id = variant["id"] + + case Client.create_order(product_id, variant_id, 1) do + {:ok, order_response} -> + order_id = order_response["id"] || "unknown" + + {:noreply, + socket + |> push_navigate(to: ~p"/orders/#{order_id}/confirmation?data=#{encode_order_data(order_response)}")} + + {:error, reason} -> + Logger.error("Failed to create order: #{inspect(reason)}") + + {:noreply, + socket + |> put_flash(:error, "Failed to create order. Please try again.") + |> assign(:error, "Failed to create order: #{inspect(reason)}")} + end + end + end + + defp parse_variant_options(product) do + option_value_lookup = build_option_value_lookup(product) + variants = product["variants"] || [] + + Enum.reduce(variants, %{}, fn variant, acc -> + option_ids = variant["options"] || [] + + Enum.reduce(option_ids, acc, fn option_id, acc2 -> + case Map.get(option_value_lookup, option_id) do + nil -> + acc2 + + {option_name, option_value} -> + Map.update(acc2, option_name, [option_value], fn existing -> + if option_value in existing, do: existing, else: [option_value | existing] + end) + end + end) + end) + |> Enum.map(fn {name, values} -> {name, Enum.reverse(values)} end) + |> Enum.into(%{}) + end + + defp build_option_value_lookup(product) do + product_options = product["options"] || [] + + Enum.reduce(product_options, %{}, fn option, acc -> + option_name = option["name"] + values = option["values"] || [] + + Enum.reduce(values, acc, fn value, acc2 -> + value_id = value["id"] + value_title = value["title"] + Map.put(acc2, value_id, {option_name, value_title}) + end) + end) + end + + defp initialize_variant_selections(variant_options) do + Enum.map(variant_options, fn {name, values} -> {name, List.first(values)} end) + |> Enum.into(%{}) + end + + defp find_matching_variant(product, selected_variants) do + option_value_lookup = build_option_value_lookup(product) + variants = product["variants"] || [] + + Enum.find(variants, fn variant -> + option_ids = variant["options"] || [] + + Enum.all?(option_ids, fn option_id -> + case Map.get(option_value_lookup, option_id) do + nil -> + false + + {option_name, option_value} -> + selected_variants[option_name] == option_value + end + end) + end) + end + + defp encode_order_data(order_response) do + Jason.encode!(order_response) |> Base.url_encode64() + end + + defp format_price(price) when is_integer(price) do + dollars = div(price, 100) + cents = rem(price, 100) + "$#{dollars}.#{String.pad_leading(Integer.to_string(cents), 2, "0")}" + end + + defp format_price(_), do: "N/A" + + @impl true + def render(assigns) do + ~H""" +
+ <%= if @loading do %> +
+
+

Loading products...

+
+ <% else %> + <%= if @error == :not_configured do %> +
+
+

Configuration Required

+

+ You need to add your Printify Personal Access Token to the configuration file. +

+ +
+

Steps:

+
    +
  1. + Visit + + printify.com/app/account/api + +
  2. +
  3. Create a new Personal Access Token
  4. +
  5. + Open config/dev.exs +
  6. +
  7. Replace your_personal_access_token_here with your actual token
  8. +
  9. Also add your shop_id
  10. +
  11. Restart the Phoenix server
  12. +
+
+ + <.link + navigate={~p"/"} + class="inline-block bg-yellow-600 text-white px-6 py-2 rounded-lg hover:bg-yellow-700" + > + ← Back to Home + +
+
+ <% else %> + <%= if @error do %> +
+

<%= @error %>

+
+ <% end %> + + <%= if @selected_product do %> + <.product_detail + product={@selected_product} + variant_options={@variant_options} + selected_variants={@selected_variants} + /> + <% else %> + <.product_list products={@products} /> + <% end %> + <% end %> + <% end %> +
+ """ + end + + defp product_list(assigns) do + ~H""" +
+

Your Printify Products

+ + <%= if @products == [] do %> +
+

No products found in your Printify shop.

+

Add products in your Printify dashboard first.

+
+ <% else %> +
+ <%= for product <- @products do %> +
+
+ <%= if product["images"] && length(product["images"]) > 0 do %> + {product["title"]} + <% else %> +
+ No image +
+ <% end %> + +
+

<%= product["title"] %>

+

+ <%= product["description"] || "No description" %> +

+ +
+
+
+ <% end %> +
+ <% end %> +
+ """ + end + + defp product_detail(assigns) do + ~H""" +
+ + +
+
+ <%= if @product["images"] && length(@product["images"]) > 0 do %> + {@product["title"]} + <% else %> +
+ No image available +
+ <% end %> +
+ +
+

<%= @product["title"] %>

+

<%= @product["description"] || "No description available" %>

+ + <%= if @variant_options && map_size(@variant_options) > 0 do %> +
+ <%= for {option_name, option_values} <- @variant_options do %> +
+ + +
+ <% end %> +
+ + <%= if variant = find_matching_variant(@product, @selected_variants) do %> +
+

+ <%= format_price(variant["price"]) %> +

+

+ Variant ID: <%= variant["id"] %> +

+
+ <% end %> + <% end %> + + + +

+ This will create a test order with hardcoded shipping address +

+
+
+
+ """ + end + + defp find_matching_variant(product, selected_variants) do + option_value_lookup = build_option_value_lookup(product) + variants = product["variants"] || [] + + Enum.find(variants, fn variant -> + option_ids = variant["options"] || [] + + Enum.all?(option_ids, fn option_id -> + case Map.get(option_value_lookup, option_id) do + nil -> + false + + {option_name, option_value} -> + selected_variants[option_name] == option_value + end + end) + end) + end +end diff --git a/lib/simpleshop_web/router.ex b/lib/simpleshop_web/router.ex index caa540f..08f3ff0 100644 --- a/lib/simpleshop_web/router.ex +++ b/lib/simpleshop_web/router.ex @@ -18,6 +18,9 @@ defmodule SimpleshopWeb.Router do pipe_through :browser get "/", PageController, :home + live "/products", ProductLive + live "/orders/:id/confirmation", OrderConfirmationLive + live "/debug", DebugLive end # Other scopes may use custom stacks. diff --git a/mix.exs b/mix.exs index e90d4ec..d7ccc0e 100644 --- a/mix.exs +++ b/mix.exs @@ -65,7 +65,8 @@ defmodule Simpleshop.MixProject do {:gettext, "~> 1.0"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, - {:bandit, "~> 1.5"} + {:bandit, "~> 1.5"}, + {:tidewave, "~> 0.5", only: :dev} ] end diff --git a/mix.lock b/mix.lock index bc8bb12..471affd 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,7 @@ %{ "bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"}, "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, @@ -41,6 +42,7 @@ "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"}, + "tidewave": {:hex, :tidewave, "0.5.2", "f549acffe9daeed8b6b547c232c60de987770da7f827f9b3300140dfc465b102", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "34ab3ffee7e402f05cd1eae68d0e77ed0e0d1925677971ef83634247553e8afd"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},