basic printify integration

This commit is contained in:
James Greenwood 2025-11-30 11:05:49 +00:00
parent dab1ffc91f
commit 39e9744eb7
16 changed files with 1181 additions and 199 deletions

View File

@ -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 */

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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);

View File

@ -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

View File

@ -1,202 +1,59 @@
<Layouts.flash_group flash={@flash} />
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
<svg
viewBox="0 0 1480 957"
fill="none"
aria-hidden="true"
class="absolute inset-0 h-full w-full"
preserveAspectRatio="xMinYMid slice"
>
<path fill="#EE7868" d="M0 0h1480v957H0z" />
<path
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
fill="#FF9F92"
/>
<path
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
fill="#FA8372"
/>
<path
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
fill="#E96856"
fill-opacity=".6"
/>
<path
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
fill="#C42652"
fill-opacity=".2"
/>
<path
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
fill="#A41C42"
fill-opacity=".2"
/>
<path
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
fill="#A41C42"
fill-opacity=".2"
/>
</svg>
</div>
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
<div class="mx-auto max-w-xl lg:mx-0">
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>
<div class="mt-10 flex justify-between items-center">
<h1 class="flex items-center text-sm font-semibold leading-6">
Phoenix Framework
<small class="badge badge-warning badge-sm ml-3">
v{Application.spec(:phoenix, :vsn)}
</small>
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center px-4">
<div class="max-w-2xl w-full text-center">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-12">
<h1 class="text-5xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Simpleshop
</h1>
<Layouts.theme_toggle />
</div>
<p class="text-xl text-gray-600 dark:text-gray-300 mb-2">
Printify Integration MVP
</p>
<p class="text-gray-500 dark:text-gray-400 mb-8">
Browse products and create test orders with your Printify catalog
</p>
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
Peace of mind from prototype to production.
</p>
<p class="mt-4 leading-7 text-base-content/70">
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.
</p>
<div class="flex">
<div class="w-full sm:w-auto">
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
<a
href="https://hexdocs.pm/phoenix/overview.html"
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path d="m12 4 10-2v18l-10 2V4Z" fill="currentColor" fill-opacity=".15" />
<path
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Guides &amp; Docs
</span>
</a>
<a
href="https://github.com/phoenixframework/phoenix"
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
/>
</svg>
Source Code
</span>
</a>
<a
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path
d="M12 1v6M12 17v6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12"
cy="12"
r="4"
fill="currentColor"
fill-opacity=".15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Changelog
</span>
</a>
</div>
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2">
<div>
<a
href="https://elixirforum.com"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
</svg>
Discuss on the Elixir Forum
</a>
</div>
<div>
<a
href="https://discord.gg/elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
</svg>
Join our Discord server
</a>
</div>
<div>
<a
href="https://elixir-slack.community/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M3.361 10.11a1.68 1.68 0 1 1-1.68-1.681h1.68v1.682ZM4.209 10.11a1.68 1.68 0 1 1 3.361 0v4.21a1.68 1.68 0 1 1-3.361 0v-4.21ZM5.89 3.361a1.68 1.68 0 1 1 1.681-1.68v1.68H5.89ZM5.89 4.209a1.68 1.68 0 1 1 0 3.361H1.68a1.68 1.68 0 1 1 0-3.361h4.21ZM12.639 5.89a1.68 1.68 0 1 1 1.68 1.681h-1.68V5.89ZM11.791 5.89a1.68 1.68 0 1 1-3.361 0V1.68a1.68 1.68 0 0 1 3.361 0v4.21ZM10.11 12.639a1.68 1.68 0 1 1-1.681 1.68v-1.68h1.682ZM10.11 11.791a1.68 1.68 0 1 1 0-3.361h4.21a1.68 1.68 0 1 1 0 3.361h-4.21Z" />
</svg>
Join us on Slack
</a>
</div>
<div>
<a
href="https://fly.io/docs/elixir/getting-started/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
</svg>
Deploy your application
</a>
</div>
</div>
<a
href="/products"
class="inline-block bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-semibold px-8 py-4 rounded-lg text-lg hover:from-blue-700 hover:to-indigo-700 transition-all transform hover:scale-105 shadow-lg"
>
View Products
</a>
<div class="mt-12 pt-8 border-t border-gray-200 dark:border-gray-700">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">Features:</h2>
<ul class="text-left text-gray-600 dark:text-gray-300 space-y-2 max-w-md mx-auto">
<li class="flex items-start">
<svg class="w-5 h-5 text-green-500 dark:text-green-400 mr-2 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span>Browse your Printify product catalog</span>
</li>
<li class="flex items-start">
<svg class="w-5 h-5 text-green-500 dark:text-green-400 mr-2 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span>View product details with variants and pricing</span>
</li>
<li class="flex items-start">
<svg class="w-5 h-5 text-green-500 dark:text-green-400 mr-2 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span>Create test orders in your Printify shop</span>
</li>
</ul>
</div>
<div class="mt-8 p-4 bg-blue-50 dark:bg-blue-900 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<strong>Setup Required:</strong> Add your Personal Access Token to <code class="bg-blue-100 dark:bg-blue-800 px-2 py-1 rounded">config/dev.exs</code>
</p>
<p class="text-xs text-blue-600 dark:text-blue-300 mt-2">
Get your token at: <a href="https://printify.com/app/account/api" target="_blank" class="underline">printify.com/app/account/api</a>
</p>
</div>
<p class="mt-6 text-xs text-gray-400 dark:text-gray-500">
This is a proof-of-concept integration using Personal Access Token
</p>
</div>
</div>
</div>

View File

@ -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

View File

@ -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"""
<div class="mx-auto max-w-4xl py-12 px-4">
<h1 class="text-3xl font-bold mb-8">Printify Shop Information</h1>
<%= if @loading do %>
<div class="text-center py-12">
<div class="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto"></div>
<p class="text-gray-600 mt-4">Fetching your shop information...</p>
</div>
<% else %>
<%= if @error do %>
<div class="bg-red-50 border border-red-200 rounded-lg p-6 mb-6">
<h2 class="text-lg font-semibold text-red-800 mb-2">Error</h2>
<p class="text-red-700 mb-4"><%= @error %></p>
<%= if @response_details do %>
<div class="mt-4 p-4 bg-white rounded border border-red-300">
<p class="text-sm font-semibold text-gray-700 mb-2">Debug Information:</p>
<div class="text-xs text-gray-600 space-y-2">
<p><strong>Status Code:</strong> <%= @response_details.status %></p>
<p><strong>Error Message:</strong> <%= @response_details.error_msg %></p>
<details class="mt-2">
<summary class="cursor-pointer text-gray-700 hover:text-gray-900">Full Response Body</summary>
<pre class="mt-2 bg-gray-100 p-2 rounded overflow-auto text-xs"><%= Jason.encode!(@response_details.body, pretty: true) %></pre>
</details>
</div>
</div>
<div class="mt-4 p-4 bg-yellow-50 rounded border border-yellow-300">
<p class="text-sm font-semibold text-yellow-900 mb-2">Troubleshooting Tips:</p>
<ul class="text-xs text-yellow-800 space-y-1 list-disc list-inside">
<li>Make sure you copied the FULL token (it's very long)</li>
<li>Check that there are no extra spaces before or after the token</li>
<li>The token should start with "eyJ"</li>
<li>Make sure the token hasn't expired</li>
<li>Try creating a new Personal Access Token in Printify</li>
</ul>
</div>
<% end %>
</div>
<% else %>
<%= if @shops == [] do %>
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<p class="text-yellow-800">No shops found for this account.</p>
</div>
<% else %>
<div class="space-y-6">
<%= for shop <- @shops do %>
<div class="bg-white border border-gray-200 rounded-lg shadow-sm p-6">
<div class="mb-4 pb-4 border-b">
<h2 class="text-2xl font-bold text-gray-900"><%= shop["title"] %></h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
<p class="text-sm font-semibold text-green-900 mb-2">Shop ID (Use this!)</p>
<p class="text-2xl font-mono font-bold text-green-700">
<%= shop["id"] %>
</p>
</div>
<div class="space-y-2">
<div>
<p class="text-sm font-medium text-gray-600">Sales Channel</p>
<p class="text-gray-900"><%= shop["sales_channel"] || "N/A" %></p>
</div>
</div>
</div>
<div class="mt-6 p-4 bg-blue-50 rounded-lg">
<p class="text-sm text-blue-800">
<strong>Next step:</strong> Copy the Shop ID above and paste it into
<code class="bg-blue-100 px-2 py-1 rounded">config/dev.exs</code>
</p>
<pre class="mt-2 text-xs bg-blue-100 p-2 rounded overflow-x-auto">shop_id: "<%= shop["id"] %>",</pre>
</div>
<details class="mt-4">
<summary class="cursor-pointer text-sm text-gray-600 hover:text-gray-800">
View full shop details (JSON)
</summary>
<pre class="mt-2 bg-gray-100 p-4 rounded-lg overflow-auto text-xs"><%= Jason.encode!(shop, pretty: true) %></pre>
</details>
</div>
<% end %>
</div>
<% end %>
<% end %>
<div class="mt-8 flex gap-4">
<.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>
<.link
navigate={~p"/products"}
class="inline-block bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
>
View Products
</.link>
</div>
<% end %>
</div>
"""
end
end

View File

@ -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"""
<div class="mx-auto max-w-2xl py-12 px-4">
<div class="text-center">
<h1 class="text-2xl font-bold mb-4">Processing Printify Authorization...</h1>
<%= if assigns[:error] do %>
<div class="bg-red-50 border border-red-200 rounded-lg p-6 mb-4">
<p class="text-red-800 font-semibold mb-2">Authentication Failed</p>
<p class="text-red-600 text-sm mb-4">
We couldn't complete the connection to Printify.
</p>
<.link
navigate={~p"/"}
class="inline-block bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
>
Return Home
</.link>
</div>
<details class="text-left bg-gray-50 border border-gray-200 rounded-lg p-4 mt-4">
<summary class="cursor-pointer font-medium text-gray-700">
Technical Details (for debugging)
</summary>
<pre class="mt-2 text-xs text-gray-600 overflow-auto"><%= @error %></pre>
</details>
<% else %>
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p class="text-gray-600 mt-4">Redirecting to products...</p>
<% end %>
</div>
</div>
"""
end
end

View File

@ -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"""
<div class="mx-auto max-w-4xl py-12 px-4">
<div class="text-center mb-8">
<div class="inline-block bg-green-100 dark:bg-green-900 rounded-full p-3 mb-4">
<svg
class="w-12 h-12 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">Order Created Successfully!</h1>
<p class="text-gray-600 dark:text-gray-300">Your test order has been submitted to Printify</p>
</div>
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Order Summary</h2>
<div class="space-y-3">
<div class="flex justify-between border-b border-gray-200 dark:border-gray-700 pb-2">
<span class="font-medium text-gray-700 dark:text-gray-300">Order ID:</span>
<span class="text-gray-900 dark:text-gray-100"><%= @order_id %></span>
</div>
<%= if @order_data["status"] do %>
<div class="flex justify-between border-b border-gray-200 dark:border-gray-700 pb-2">
<span class="font-medium text-gray-700 dark:text-gray-300">Status:</span>
<span class="px-3 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-sm font-medium">
<%= @order_data["status"] %>
</span>
</div>
<% end %>
<%= if @order_data["created_at"] do %>
<div class="flex justify-between border-b border-gray-200 dark:border-gray-700 pb-2">
<span class="font-medium text-gray-700 dark:text-gray-300">Created:</span>
<span class="text-gray-900 dark:text-gray-100"><%= @order_data["created_at"] %></span>
</div>
<% end %>
<%= if line_items = @order_data["line_items"] do %>
<div class="border-b border-gray-200 dark:border-gray-700 pb-2">
<span class="font-medium text-gray-700 dark:text-gray-300 block mb-2">Line Items:</span>
<%= for item <- line_items do %>
<div class="ml-4 text-sm text-gray-600 dark:text-gray-400">
<span>Product: <%= item["product_id"] %></span>
<span class="ml-4">Variant: <%= item["variant_id"] %></span>
<span class="ml-4">Qty: <%= item["quantity"] %></span>
</div>
<% end %>
</div>
<% end %>
<%= if address = @order_data["address_to"] do %>
<div class="border-b border-gray-200 dark:border-gray-700 pb-2">
<span class="font-medium text-gray-700 dark:text-gray-300 block mb-2">Shipping Address:</span>
<div class="ml-4 text-sm text-gray-600 dark:text-gray-400">
<p><%= address["first_name"] %> <%= address["last_name"] %></p>
<p><%= address["address1"] %></p>
<%= if address["address2"] do %>
<p><%= address["address2"] %></p>
<% end %>
<p>
<%= address["city"] %>, <%= address["region"] %> <%= address["zip"] %>
</p>
<p><%= address["country"] %></p>
<%= if address["email"] do %>
<p class="mt-1">Email: <%= address["email"] %></p>
<% end %>
<%= if address["phone"] do %>
<p>Phone: <%= address["phone"] %></p>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6">
<details>
<summary class="cursor-pointer font-semibold text-gray-900 dark:text-gray-100 mb-2">
Full API Response (for debugging)
</summary>
<pre class="bg-gray-900 dark:bg-gray-950 text-green-400 dark:text-green-300 p-4 rounded-lg overflow-auto text-xs mt-4"><%= Jason.encode!(@order_data, pretty: true) %></pre>
</details>
</div>
<div class="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg p-4 mb-6">
<p class="text-blue-800 dark:text-blue-200">
<span class="font-semibold">Next steps:</span>
Check your Printify dashboard to see this order and manage its fulfillment.
</p>
</div>
<div class="flex gap-4 justify-center">
<.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
</.link>
<a
href="https://printify.com/app/orders"
target="_blank"
class="bg-gray-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-gray-700 transition-colors"
>
View in Printify Dashboard
</a>
</div>
<p class="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
This was a test order with a hardcoded shipping address.
</p>
</div>
"""
end
end

View File

@ -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"""
<div class="mx-auto max-w-6xl py-12 px-4">
<%= if @loading do %>
<div class="text-center py-12">
<div class="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto"></div>
<p class="text-gray-600 mt-4">Loading products...</p>
</div>
<% else %>
<%= if @error == :not_configured do %>
<div class="max-w-2xl mx-auto">
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-8 mb-6">
<h2 class="text-2xl font-bold text-yellow-900 mb-4">Configuration Required</h2>
<p class="text-yellow-800 mb-4">
You need to add your Printify Personal Access Token to the configuration file.
</p>
<div class="bg-white rounded p-4 mb-4">
<p class="text-sm font-semibold text-gray-700 mb-2">Steps:</p>
<ol class="list-decimal list-inside space-y-2 text-sm text-gray-600">
<li>
Visit
<a
href="https://printify.com/app/account/api"
target="_blank"
class="text-blue-600 underline"
>
printify.com/app/account/api
</a>
</li>
<li>Create a new Personal Access Token</li>
<li>
Open <code class="bg-gray-100 px-2 py-1 rounded">config/dev.exs</code>
</li>
<li>Replace <code class="bg-gray-100 px-2 py-1 rounded">your_personal_access_token_here</code> with your actual token</li>
<li>Also add your <code class="bg-gray-100 px-2 py-1 rounded">shop_id</code></li>
<li>Restart the Phoenix server</li>
</ol>
</div>
<.link
navigate={~p"/"}
class="inline-block bg-yellow-600 text-white px-6 py-2 rounded-lg hover:bg-yellow-700"
>
Back to Home
</.link>
</div>
</div>
<% else %>
<%= if @error do %>
<div class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p class="text-red-800"><%= @error %></p>
</div>
<% 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 %>
</div>
"""
end
defp product_list(assigns) do
~H"""
<div>
<h1 class="text-3xl font-bold mb-8">Your Printify Products</h1>
<%= if @products == [] do %>
<div class="text-center py-12 bg-gray-50 rounded-lg">
<p class="text-gray-600 text-lg">No products found in your Printify shop.</p>
<p class="text-gray-500 mt-2">Add products in your Printify dashboard first.</p>
</div>
<% else %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<%= for product <- @products do %>
<div class="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow cursor-pointer">
<div phx-click="select_product" phx-value-id={product["id"]}>
<%= if product["images"] && length(product["images"]) > 0 do %>
<img
src={hd(product["images"])["src"]}
alt={product["title"]}
class="w-full h-64 object-cover"
/>
<% else %>
<div class="w-full h-64 bg-gray-200 flex items-center justify-center">
<span class="text-gray-400">No image</span>
</div>
<% end %>
<div class="p-4">
<h3 class="font-semibold text-lg mb-2"><%= product["title"] %></h3>
<p class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2">
<%= product["description"] || "No description" %>
</p>
<button class="mt-4 w-full bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700">
View Details
</button>
</div>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
"""
end
defp product_detail(assigns) do
~H"""
<div>
<button
phx-click="back_to_list"
class="mb-6 text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center underline"
>
Back to Products
</button>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<%= if @product["images"] && length(@product["images"]) > 0 do %>
<img
src={hd(@product["images"])["src"]}
alt={@product["title"]}
class="w-full rounded-lg shadow-lg"
/>
<% else %>
<div class="w-full h-96 bg-gray-200 rounded-lg flex items-center justify-center">
<span class="text-gray-400">No image available</span>
</div>
<% end %>
</div>
<div>
<h1 class="text-3xl font-bold mb-4"><%= @product["title"] %></h1>
<p class="text-gray-900 dark:text-gray-100 mb-6"><%= @product["description"] || "No description available" %></p>
<%= if @variant_options && map_size(@variant_options) > 0 do %>
<div class="space-y-4 mb-6">
<%= for {option_name, option_values} <- @variant_options do %>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
<%= option_name %>
</label>
<select
phx-change="update_variant"
name="variant_option"
phx-value-option={option_name}
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<%= for value <- option_values do %>
<option value={value} selected={@selected_variants[option_name] == value}>
<%= value %>
</option>
<% end %>
</select>
</div>
<% end %>
</div>
<%= if variant = find_matching_variant(@product, @selected_variants) do %>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<p class="text-2xl font-bold text-blue-900">
<%= format_price(variant["price"]) %>
</p>
<p class="text-sm text-blue-700 mt-1">
Variant ID: <%= variant["id"] %>
</p>
</div>
<% end %>
<% end %>
<button
phx-click="checkout"
class="w-full bg-green-600 text-white px-6 py-3 rounded-lg font-semibold hover:bg-green-700 transition-colors"
>
Checkout (Test Order)
</button>
<p class="text-sm text-gray-500 mt-3 text-center">
This will create a test order with hardcoded shipping address
</p>
</div>
</div>
</div>
"""
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

View File

@ -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.

View File

@ -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

View File

@ -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"},