basic printify integration
This commit is contained in:
parent
dab1ffc91f
commit
39e9744eb7
@ -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 */
|
||||
|
||||
@ -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"
|
||||
|
||||
124
lib/simpleshop/printify/client.ex
Normal file
124
lib/simpleshop/printify/client.ex
Normal 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
|
||||
92
lib/simpleshop/printify/oauth.ex
Normal file
92
lib/simpleshop/printify/oauth.ex
Normal 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
|
||||
24
lib/simpleshop/printify/token_store.ex
Normal file
24
lib/simpleshop/printify/token_store.ex
Normal 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
|
||||
@ -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);
|
||||
|
||||
9
lib/simpleshop_web/controllers/oauth_controller.ex
Normal file
9
lib/simpleshop_web/controllers/oauth_controller.ex
Normal 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
|
||||
@ -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 />
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
|
||||
Peace of mind from prototype to production.
|
||||
<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="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 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 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 & 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>
|
||||
</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>
|
||||
|
||||
@ -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
|
||||
|
||||
194
lib/simpleshop_web/live/debug_live.ex
Normal file
194
lib/simpleshop_web/live/debug_live.ex
Normal 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
|
||||
78
lib/simpleshop_web/live/oauth_callback_live.ex
Normal file
78
lib/simpleshop_web/live/oauth_callback_live.ex
Normal 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
|
||||
158
lib/simpleshop_web/live/order_confirmation_live.ex
Normal file
158
lib/simpleshop_web/live/order_confirmation_live.ex
Normal 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
|
||||
423
lib/simpleshop_web/live/product_live.ex
Normal file
423
lib/simpleshop_web/live/product_live.ex
Normal 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
|
||||
@ -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.
|
||||
|
||||
3
mix.exs
3
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
|
||||
|
||||
|
||||
2
mix.lock
2
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"},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user