feat: add Products context with provider integration (Phase 1)
Implement the schema foundation for syncing products from POD providers like Printify. This includes encrypted credential storage, product/variant schemas, and an Oban worker for background sync. New modules: - Vault: AES-256-GCM encryption for API keys - Products context: CRUD and sync operations for products - Provider behaviour: abstraction for POD provider implementations - ProductSyncWorker: Oban job for async product sync Schemas: ProviderConnection, Product, ProductImage, ProductVariant Also reorganizes Printify client to lib/simpleshop_theme/clients/ and mockup generator to lib/simpleshop_theme/mockups/ for better structure. 134 tests added covering all new functionality. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
488
lib/simpleshop_theme/mockups/generator.ex
Normal file
488
lib/simpleshop_theme/mockups/generator.ex
Normal file
@@ -0,0 +1,488 @@
|
||||
defmodule SimpleshopTheme.Mockups.Generator do
|
||||
@moduledoc """
|
||||
Generates product mockups using the Printify API.
|
||||
|
||||
This module handles the end-to-end process of:
|
||||
1. Looking up product blueprints and variants
|
||||
2. Downloading artwork from Unsplash
|
||||
3. Uploading artwork to Printify
|
||||
4. Creating products with the artwork
|
||||
5. Downloading generated mockup images
|
||||
6. Optionally cleaning up created products
|
||||
"""
|
||||
|
||||
alias SimpleshopTheme.Clients.Printify, as: Client
|
||||
|
||||
@output_dir "priv/static/mockups"
|
||||
|
||||
@doc """
|
||||
Product definitions with their artwork URLs and Printify product types.
|
||||
"""
|
||||
def product_definitions do
|
||||
[
|
||||
%{
|
||||
name: "Mountain Sunrise Art Print",
|
||||
slug: "mountain-sunrise-print",
|
||||
category: "Art Prints",
|
||||
artwork_url: unsplash_download_url("UweNcthlmDc"),
|
||||
product_type: :poster,
|
||||
price: 2400
|
||||
},
|
||||
%{
|
||||
name: "Ocean Waves Art Print",
|
||||
slug: "ocean-waves-print",
|
||||
category: "Art Prints",
|
||||
artwork_url: unsplash_download_url("XRhUTUVuXAE"),
|
||||
product_type: :poster,
|
||||
price: 2400
|
||||
},
|
||||
%{
|
||||
name: "Wildflower Meadow Art Print",
|
||||
slug: "wildflower-meadow-print",
|
||||
category: "Art Prints",
|
||||
artwork_url: unsplash_download_url("QvjL4y7SF9k"),
|
||||
product_type: :poster,
|
||||
price: 2400
|
||||
},
|
||||
%{
|
||||
name: "Geometric Abstract Art Print",
|
||||
slug: "geometric-abstract-print",
|
||||
category: "Art Prints",
|
||||
artwork_url: unsplash_download_url("-6GvTDpkkPU"),
|
||||
product_type: :poster,
|
||||
price: 2800
|
||||
},
|
||||
%{
|
||||
name: "Botanical Illustration Print",
|
||||
slug: "botanical-illustration-print",
|
||||
category: "Art Prints",
|
||||
artwork_url: unsplash_download_url("FNtNIDQWUZY"),
|
||||
product_type: :poster,
|
||||
price: 2400
|
||||
},
|
||||
%{
|
||||
name: "Forest Silhouette T-Shirt",
|
||||
slug: "forest-silhouette-tshirt",
|
||||
category: "Apparel",
|
||||
artwork_url: unsplash_download_url("EhvMzMRO4_o"),
|
||||
product_type: :tshirt,
|
||||
price: 2999
|
||||
},
|
||||
%{
|
||||
name: "Forest Light Hoodie",
|
||||
slug: "forest-light-hoodie",
|
||||
category: "Apparel",
|
||||
artwork_url: unsplash_download_url("FwVkxITt8Bg"),
|
||||
product_type: :hoodie,
|
||||
price: 4499
|
||||
},
|
||||
%{
|
||||
name: "Wildflower Meadow Tote Bag",
|
||||
slug: "wildflower-meadow-tote",
|
||||
category: "Apparel",
|
||||
artwork_url: unsplash_download_url("QvjL4y7SF9k"),
|
||||
product_type: :tote,
|
||||
price: 1999
|
||||
},
|
||||
%{
|
||||
name: "Sunset Gradient Tote Bag",
|
||||
slug: "sunset-gradient-tote",
|
||||
category: "Apparel",
|
||||
artwork_url: unsplash_download_url("XRhUTUVuXAE"),
|
||||
product_type: :tote,
|
||||
price: 1999
|
||||
},
|
||||
%{
|
||||
name: "Fern Leaf Mug",
|
||||
slug: "fern-leaf-mug",
|
||||
category: "Homewares",
|
||||
artwork_url: unsplash_download_url("bYiJojtkHnc"),
|
||||
product_type: :mug,
|
||||
price: 1499
|
||||
},
|
||||
%{
|
||||
name: "Ocean Waves Cushion",
|
||||
slug: "ocean-waves-cushion",
|
||||
category: "Homewares",
|
||||
artwork_url: unsplash_download_url("XRhUTUVuXAE"),
|
||||
product_type: :cushion,
|
||||
price: 2999
|
||||
},
|
||||
%{
|
||||
name: "Night Sky Blanket",
|
||||
slug: "night-sky-blanket",
|
||||
category: "Homewares",
|
||||
artwork_url: unsplash_download_url("oQR1B87HsNs"),
|
||||
product_type: :blanket,
|
||||
price: 5999
|
||||
},
|
||||
%{
|
||||
name: "Autumn Leaves Notebook",
|
||||
slug: "autumn-leaves-notebook",
|
||||
category: "Stationery",
|
||||
artwork_url: unsplash_download_url("Aa3ALtIxEGY"),
|
||||
product_type: :notebook,
|
||||
price: 1999
|
||||
},
|
||||
%{
|
||||
name: "Monstera Leaf Notebook",
|
||||
slug: "monstera-leaf-notebook",
|
||||
category: "Stationery",
|
||||
artwork_url: unsplash_download_url("hETU8_b2IM0"),
|
||||
product_type: :notebook,
|
||||
price: 1999
|
||||
},
|
||||
%{
|
||||
name: "Monstera Leaf Phone Case",
|
||||
slug: "monstera-leaf-phone-case",
|
||||
category: "Accessories",
|
||||
artwork_url: unsplash_download_url("hETU8_b2IM0"),
|
||||
product_type: :phone_case,
|
||||
price: 2499
|
||||
},
|
||||
%{
|
||||
name: "Blue Waves Laptop Sleeve",
|
||||
slug: "blue-waves-laptop-sleeve",
|
||||
category: "Accessories",
|
||||
artwork_url: unsplash_download_url("dYksH3vHorc"),
|
||||
product_type: :laptop_sleeve,
|
||||
price: 3499
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Blueprint configurations for each product type.
|
||||
These IDs need to be looked up from the Printify catalog.
|
||||
"""
|
||||
def blueprint_config do
|
||||
%{
|
||||
# Search terms matched to Printify's actual blueprint titles (partial match)
|
||||
poster: %{blueprint_id: nil, print_provider_id: nil, search_term: "Matte Posters"},
|
||||
tshirt: %{blueprint_id: nil, print_provider_id: nil, search_term: "Softstyle T-Shirt"},
|
||||
hoodie: %{blueprint_id: nil, print_provider_id: nil, search_term: "Pullover Hoodie"},
|
||||
tote: %{blueprint_id: nil, print_provider_id: nil, search_term: "Cotton Tote Bag"},
|
||||
mug: %{blueprint_id: nil, print_provider_id: nil, search_term: "Mug 11oz"},
|
||||
cushion: %{blueprint_id: nil, print_provider_id: nil, search_term: "Spun Polyester Square Pillow"},
|
||||
blanket: %{blueprint_id: nil, print_provider_id: nil, search_term: "Sherpa Fleece Blanket"},
|
||||
notebook: %{blueprint_id: nil, print_provider_id: nil, search_term: "Hardcover Journal Matte"},
|
||||
phone_case: %{blueprint_id: nil, print_provider_id: nil, search_term: "Tough Phone Cases"},
|
||||
laptop_sleeve: %{blueprint_id: nil, print_provider_id: nil, search_term: "Laptop Sleeve"}
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generate Unsplash download URL from photo ID.
|
||||
Uses the Unsplash download API which provides high-quality images.
|
||||
"""
|
||||
def unsplash_download_url(photo_id) do
|
||||
"https://unsplash.com/photos/#{photo_id}/download?force=true"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Search for a blueprint by name/term.
|
||||
"""
|
||||
def find_blueprint(search_term) do
|
||||
case Client.get_blueprints() do
|
||||
{:ok, blueprints} ->
|
||||
found =
|
||||
Enum.find(blueprints, fn bp ->
|
||||
String.contains?(String.downcase(bp["title"] || ""), String.downcase(search_term))
|
||||
end)
|
||||
|
||||
case found do
|
||||
nil -> {:error, {:blueprint_not_found, search_term}}
|
||||
bp -> {:ok, bp}
|
||||
end
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Find a suitable print provider for a blueprint.
|
||||
Prefers providers with good ratings and reasonable pricing.
|
||||
"""
|
||||
def find_print_provider(blueprint_id) do
|
||||
case Client.get_print_providers(blueprint_id) do
|
||||
{:ok, providers} when is_list(providers) and length(providers) > 0 ->
|
||||
# Just pick the first provider for simplicity
|
||||
{:ok, hd(providers)}
|
||||
|
||||
{:ok, []} ->
|
||||
{:error, :no_providers}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get variant and placeholder information for a blueprint/provider combination.
|
||||
"""
|
||||
def get_variant_info(blueprint_id, print_provider_id) do
|
||||
case Client.get_variants(blueprint_id, print_provider_id) do
|
||||
{:ok, %{"variants" => variants}} when is_list(variants) ->
|
||||
{:ok, variants}
|
||||
|
||||
{:ok, variants} when is_list(variants) ->
|
||||
{:ok, variants}
|
||||
|
||||
{:ok, response} when is_map(response) ->
|
||||
# Handle case where variants might be nested differently
|
||||
{:ok, response["variants"] || []}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Upload artwork to Printify from a URL.
|
||||
"""
|
||||
def upload_artwork(name, url) do
|
||||
file_name = "#{name}.jpg"
|
||||
Client.upload_image(file_name, url)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculate scale factor for "cover" behavior.
|
||||
Image will fill entire placeholder, cropping edges if necessary.
|
||||
Printify scale is relative to placeholder width (1.0 = artwork width matches placeholder width).
|
||||
"""
|
||||
def calculate_cover_scale(artwork_width, artwork_height, placeholder_width, placeholder_height)
|
||||
when is_number(artwork_width) and is_number(artwork_height) and
|
||||
is_number(placeholder_width) and is_number(placeholder_height) and
|
||||
artwork_width > 0 and artwork_height > 0 and
|
||||
placeholder_width > 0 and placeholder_height > 0 do
|
||||
# For cover: use the larger scale to ensure full coverage
|
||||
width_scale = 1.0
|
||||
height_scale = (placeholder_height * artwork_width) / (artwork_height * placeholder_width)
|
||||
max(width_scale, height_scale)
|
||||
end
|
||||
|
||||
def calculate_cover_scale(_, _, _, _), do: 1.0
|
||||
|
||||
@doc """
|
||||
Create a product with the uploaded artwork.
|
||||
"""
|
||||
def create_product(shop_id, product_def, image_id, image_width, image_height, blueprint_id, print_provider_id, variants) do
|
||||
# Get the first variant for simplicity (typically a standard size/color)
|
||||
variant = hd(variants)
|
||||
variant_id = variant["id"]
|
||||
|
||||
# Get placeholder info
|
||||
placeholders = variant["placeholders"] || []
|
||||
front_placeholder = Enum.find(placeholders, fn p -> p["position"] == "front" end) || hd(placeholders)
|
||||
|
||||
# Extract placeholder dimensions and calculate cover scale
|
||||
placeholder_width = front_placeholder["width"]
|
||||
placeholder_height = front_placeholder["height"]
|
||||
scale = calculate_cover_scale(image_width, image_height, placeholder_width, placeholder_height)
|
||||
|
||||
IO.puts(" Scale calculation: artwork #{image_width}x#{image_height}, placeholder #{placeholder_width}x#{placeholder_height} -> scale #{Float.round(scale, 3)}")
|
||||
|
||||
product_data = %{
|
||||
title: product_def.name,
|
||||
description: "#{product_def.name} - Nature-inspired design from Wildprint Studio",
|
||||
blueprint_id: blueprint_id,
|
||||
print_provider_id: print_provider_id,
|
||||
variants: [
|
||||
%{
|
||||
id: variant_id,
|
||||
price: product_def.price,
|
||||
is_enabled: true
|
||||
}
|
||||
],
|
||||
print_areas: [
|
||||
%{
|
||||
variant_ids: [variant_id],
|
||||
placeholders: [
|
||||
%{
|
||||
position: front_placeholder["position"] || "front",
|
||||
images: [
|
||||
%{
|
||||
id: image_id,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
scale: scale,
|
||||
angle: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Client.create_product(shop_id, product_data)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Extract mockup image URLs from a created product.
|
||||
"""
|
||||
def extract_mockup_urls(product) do
|
||||
images = product["images"] || []
|
||||
Enum.map(images, fn img -> img["src"] end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Download mockup images, save as WebP source, and generate variants.
|
||||
Sources are saved for regeneration on startup via VariantCache.
|
||||
"""
|
||||
def download_mockups(product_slug, mockup_urls) do
|
||||
alias SimpleshopTheme.Images.Optimizer
|
||||
|
||||
File.mkdir_p!(@output_dir)
|
||||
|
||||
mockup_urls
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.map(fn {url, index} ->
|
||||
basename = "#{product_slug}-#{index}"
|
||||
source_path = Path.join(@output_dir, "#{basename}.webp")
|
||||
IO.puts(" Processing mockup #{index}...")
|
||||
|
||||
temp_path = Path.join(System.tmp_dir!(), "#{basename}-temp.jpg")
|
||||
|
||||
with {:ok, _} <- Client.download_file(url, temp_path),
|
||||
{:ok, image_data} <- File.read(temp_path),
|
||||
{:ok, webp_data, source_width, _} <- Optimizer.to_optimized_webp(image_data),
|
||||
:ok <- File.write(source_path, webp_data),
|
||||
{:ok, _} <- Optimizer.process_file(webp_data, basename, @output_dir) do
|
||||
File.rm(temp_path)
|
||||
IO.puts(" Saved source + variants for #{basename} (#{source_width}px)")
|
||||
{:ok, basename, source_width}
|
||||
else
|
||||
{:error, reason} ->
|
||||
File.rm(temp_path)
|
||||
{:error, {url, reason}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generate mockups for all products.
|
||||
"""
|
||||
def generate_all(opts \\ []) do
|
||||
cleanup = Keyword.get(opts, :cleanup, false)
|
||||
|
||||
IO.puts("Starting mockup generation...")
|
||||
IO.puts("")
|
||||
|
||||
# Get shop ID
|
||||
IO.puts("Fetching shop ID...")
|
||||
{:ok, shop_id} = Client.get_shop_id()
|
||||
IO.puts("Using shop ID: #{shop_id}")
|
||||
IO.puts("")
|
||||
|
||||
results =
|
||||
product_definitions()
|
||||
|> Enum.map(fn product_def ->
|
||||
IO.puts("Processing: #{product_def.name}")
|
||||
result = generate_single(shop_id, product_def)
|
||||
|
||||
case result do
|
||||
{:ok, product_id, mockup_paths} ->
|
||||
IO.puts(" ✓ Generated #{length(mockup_paths)} mockups")
|
||||
{:ok, product_def.slug, product_id, mockup_paths}
|
||||
|
||||
{:error, reason} ->
|
||||
IO.puts(" ✗ Error: #{inspect(reason)}")
|
||||
{:error, product_def.slug, reason}
|
||||
end
|
||||
end)
|
||||
|
||||
# Cleanup if requested
|
||||
if cleanup do
|
||||
IO.puts("")
|
||||
IO.puts("Cleaning up created products...")
|
||||
|
||||
results
|
||||
|> Enum.filter(fn
|
||||
{:ok, _, _, _} -> true
|
||||
_ -> false
|
||||
end)
|
||||
|> Enum.each(fn {:ok, slug, product_id, _} ->
|
||||
IO.puts(" Deleting #{slug}...")
|
||||
Client.delete_product(shop_id, product_id)
|
||||
end)
|
||||
|
||||
IO.puts("Cleanup complete.")
|
||||
end
|
||||
|
||||
IO.puts("")
|
||||
IO.puts("Mockup generation complete!")
|
||||
IO.puts("Output directory: #{@output_dir}")
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generate mockups for a single product.
|
||||
"""
|
||||
def generate_single(shop_id, product_def) do
|
||||
config = blueprint_config()[product_def.product_type]
|
||||
|
||||
with {:ok, blueprint} <- find_blueprint(config.search_term),
|
||||
blueprint_id = blueprint["id"],
|
||||
_ = IO.puts(" Found blueprint: #{blueprint["title"]} (#{blueprint_id})"),
|
||||
{:ok, provider} <- find_print_provider(blueprint_id),
|
||||
provider_id = provider["id"],
|
||||
_ = IO.puts(" Using provider: #{provider["title"]} (#{provider_id})"),
|
||||
{:ok, variants} <- get_variant_info(blueprint_id, provider_id),
|
||||
_ = IO.puts(" Found #{length(variants)} variants"),
|
||||
_ = IO.puts(" Uploading artwork..."),
|
||||
{:ok, upload} <- upload_artwork(product_def.slug, product_def.artwork_url),
|
||||
image_id = upload["id"],
|
||||
image_width = upload["width"],
|
||||
image_height = upload["height"],
|
||||
_ = IO.puts(" Artwork uploaded (ID: #{image_id}, #{image_width}x#{image_height})"),
|
||||
_ = IO.puts(" Creating product..."),
|
||||
{:ok, product} <- create_product(shop_id, product_def, image_id, image_width, image_height, blueprint_id, provider_id, variants),
|
||||
product_id = product["id"],
|
||||
mockup_urls = extract_mockup_urls(product),
|
||||
_ = IO.puts(" Product created (ID: #{product_id})"),
|
||||
_ = IO.puts(" Downloading #{length(mockup_urls)} mockups..."),
|
||||
download_results <- download_mockups(product_def.slug, mockup_urls) do
|
||||
successful_downloads =
|
||||
download_results
|
||||
|> Enum.filter(&match?({:ok, _}, &1))
|
||||
|> Enum.map(fn {:ok, path} -> path end)
|
||||
|
||||
{:ok, product_id, successful_downloads}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
List all available blueprints (for discovery).
|
||||
"""
|
||||
def list_blueprints do
|
||||
case Client.get_blueprints() do
|
||||
{:ok, blueprints} ->
|
||||
blueprints
|
||||
|> Enum.map(fn bp -> {bp["id"], bp["title"]} end)
|
||||
|> Enum.sort_by(fn {_, title} -> title end)
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Search blueprints by keyword.
|
||||
"""
|
||||
def search_blueprints(keyword) do
|
||||
case Client.get_blueprints() do
|
||||
{:ok, blueprints} ->
|
||||
blueprints
|
||||
|> Enum.filter(fn bp ->
|
||||
String.contains?(String.downcase(bp["title"] || ""), String.downcase(keyword))
|
||||
end)
|
||||
|> Enum.map(fn bp -> {bp["id"], bp["title"]} end)
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user