add per-colour product images and gallery colour filtering
Tag product images with their colour during sync (both Printful and Printify providers). Printify images are cherry-picked: hero colour keeps all angles, other colours keep front + back only. Printful MockupEnricher now generates mockups per colour from the color_variant_map. PDP gallery filters by the selected colour, falling back to all images when the selected colour has none. Fix option name mismatch (Printify "Colors" vs variant "Color") by singularizing in Product.option_types. Generator creates multi-colour apparel products so mock data matches real sync behaviour. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
29d8839ac2
commit
daa6d3de71
@ -56,6 +56,24 @@ defmodule SimpleshopTheme.Clients.Printify do
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Make a PUT request to the Printify API.
|
||||
"""
|
||||
def put(path, body, _opts \\ []) do
|
||||
url = @base_url <> path
|
||||
|
||||
case Req.put(url, json: body, headers: auth_headers(), receive_timeout: 60_000) do
|
||||
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
|
||||
{:ok, body}
|
||||
|
||||
{:ok, %Req.Response{status: status, body: body}} ->
|
||||
{:error, {status, body}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Make a DELETE request to the Printify API.
|
||||
"""
|
||||
@ -162,6 +180,13 @@ defmodule SimpleshopTheme.Clients.Printify do
|
||||
get("/shops/#{shop_id}/products.json?limit=#{limit}&page=#{page}")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Update a product in a shop.
|
||||
"""
|
||||
def update_product(shop_id, product_id, product_data) do
|
||||
put("/shops/#{shop_id}/products/#{product_id}.json", product_data)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Delete a product from a shop.
|
||||
"""
|
||||
|
||||
@ -66,7 +66,8 @@ defmodule SimpleshopTheme.Mockups.Generator do
|
||||
category: "Apparel",
|
||||
artwork_url: unsplash_download_url("EhvMzMRO4_o"),
|
||||
product_type: :tshirt,
|
||||
price: 2999
|
||||
price: 2999,
|
||||
colors: ["Black", "White", "Sport Grey", "Forest Green"]
|
||||
},
|
||||
%{
|
||||
name: "Forest Light Hoodie",
|
||||
@ -74,7 +75,8 @@ defmodule SimpleshopTheme.Mockups.Generator do
|
||||
category: "Apparel",
|
||||
artwork_url: unsplash_download_url("FwVkxITt8Bg"),
|
||||
product_type: :hoodie,
|
||||
price: 4499
|
||||
price: 4499,
|
||||
colors: ["Dark Heather", "Navy", "Forest Green", "Sand"]
|
||||
},
|
||||
%{
|
||||
name: "Wildflower Meadow Tote Bag",
|
||||
@ -275,6 +277,10 @@ defmodule SimpleshopTheme.Mockups.Generator do
|
||||
|
||||
@doc """
|
||||
Create a product with the uploaded artwork.
|
||||
|
||||
When the product definition includes a `colors` list, enables one variant
|
||||
per colour (picking a middle size for each). Printify generates mockup
|
||||
images for every enabled colour automatically.
|
||||
"""
|
||||
def create_product(
|
||||
shop_id,
|
||||
@ -286,17 +292,17 @@ defmodule SimpleshopTheme.Mockups.Generator do
|
||||
print_provider_id,
|
||||
variants
|
||||
) do
|
||||
# Get the first variant for simplicity (typically a standard size/color)
|
||||
variant = hd(variants)
|
||||
variant_id = variant["id"]
|
||||
selected_variants = select_variants(variants, product_def)
|
||||
|
||||
# Get placeholder info
|
||||
IO.puts(" Enabling #{length(selected_variants)} variant(s)")
|
||||
|
||||
# Use the first selected variant for placeholder/scale calculations
|
||||
variant = hd(selected_variants)
|
||||
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"]
|
||||
|
||||
@ -307,32 +313,25 @@ defmodule SimpleshopTheme.Mockups.Generator do
|
||||
" Scale calculation: artwork #{image_width}x#{image_height}, placeholder #{placeholder_width}x#{placeholder_height} -> scale #{Float.round(scale, 3)}"
|
||||
)
|
||||
|
||||
variant_ids = Enum.map(selected_variants, & &1["id"])
|
||||
|
||||
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
|
||||
}
|
||||
],
|
||||
variants:
|
||||
Enum.map(selected_variants, fn v ->
|
||||
%{id: v["id"], price: product_def.price, is_enabled: true}
|
||||
end),
|
||||
print_areas: [
|
||||
%{
|
||||
variant_ids: [variant_id],
|
||||
variant_ids: variant_ids,
|
||||
placeholders: [
|
||||
%{
|
||||
position: front_placeholder["position"] || "front",
|
||||
images: [
|
||||
%{
|
||||
id: image_id,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
scale: scale,
|
||||
angle: 0
|
||||
}
|
||||
%{id: image_id, x: 0.5, y: 0.5, scale: scale, angle: 0}
|
||||
]
|
||||
}
|
||||
]
|
||||
@ -343,6 +342,33 @@ defmodule SimpleshopTheme.Mockups.Generator do
|
||||
Client.create_product(shop_id, product_data)
|
||||
end
|
||||
|
||||
# Pick one variant per requested colour (middle size), or fall back to hd.
|
||||
defp select_variants(variants, %{colors: colors}) when is_list(colors) and colors != [] do
|
||||
# Group variants by the colour portion of their title ("Dark Heather / L" → "Dark Heather")
|
||||
by_color =
|
||||
Enum.group_by(variants, fn v ->
|
||||
v["title"] |> to_string() |> String.split(" / ") |> hd() |> String.trim()
|
||||
end)
|
||||
|
||||
selected =
|
||||
Enum.flat_map(colors, fn color ->
|
||||
case Map.get(by_color, color) do
|
||||
nil ->
|
||||
IO.puts(" Warning: colour #{inspect(color)} not found in blueprint variants")
|
||||
[]
|
||||
|
||||
color_variants ->
|
||||
# Pick the middle variant (typically a medium size)
|
||||
mid = div(length(color_variants), 2)
|
||||
[Enum.at(color_variants, mid)]
|
||||
end
|
||||
end)
|
||||
|
||||
if selected == [], do: [hd(variants)], else: selected
|
||||
end
|
||||
|
||||
defp select_variants(variants, _product_def), do: [hd(variants)]
|
||||
|
||||
@doc """
|
||||
Extract mockup image URLs from a created product.
|
||||
"""
|
||||
|
||||
@ -525,14 +525,25 @@ defmodule SimpleshopTheme.Products do
|
||||
existing = Map.get(existing_by_position, position)
|
||||
|
||||
cond do
|
||||
# Same URL at position - keep existing (preserve image_id)
|
||||
# Same URL at position - update color if needed, preserve image_id
|
||||
existing && existing.src == src ->
|
||||
if existing.color != image_data[:color] do
|
||||
existing
|
||||
|> ProductImage.changeset(%{color: image_data[:color]})
|
||||
|> Repo.update()
|
||||
else
|
||||
{:ok, existing}
|
||||
end
|
||||
|
||||
# Different URL at position - update src, clear image_id (triggers re-download)
|
||||
existing ->
|
||||
existing
|
||||
|> ProductImage.changeset(%{src: src, alt: image_data[:alt], image_id: nil})
|
||||
|> ProductImage.changeset(%{
|
||||
src: src,
|
||||
alt: image_data[:alt],
|
||||
color: image_data[:color],
|
||||
image_id: nil
|
||||
})
|
||||
|> Repo.update()
|
||||
|
||||
# New position - create new
|
||||
|
||||
@ -119,7 +119,7 @@ defmodule SimpleshopTheme.Products.Product do
|
||||
end
|
||||
end)
|
||||
|
||||
%{name: opt["name"], type: type, values: values}
|
||||
%{name: singularize_option_name(opt["name"]), type: type, values: values}
|
||||
end)
|
||||
end
|
||||
|
||||
@ -129,6 +129,12 @@ defmodule SimpleshopTheme.Products.Product do
|
||||
defp option_type_atom("color"), do: :color
|
||||
defp option_type_atom(_), do: :size
|
||||
|
||||
# Printify sends plural names ("Colors", "Sizes") but variant options
|
||||
# use singular — keep them consistent so gallery filtering works.
|
||||
defp singularize_option_name("Colors"), do: "Color"
|
||||
defp singularize_option_name("Sizes"), do: "Size"
|
||||
defp singularize_option_name(name), do: name
|
||||
|
||||
@doc """
|
||||
Generates a checksum from provider data for detecting changes.
|
||||
"""
|
||||
|
||||
@ -15,6 +15,7 @@ defmodule SimpleshopTheme.Products.ProductImage do
|
||||
field :src, :string
|
||||
field :position, :integer, default: 0
|
||||
field :alt, :string
|
||||
field :color, :string
|
||||
field :image_id, :binary_id
|
||||
|
||||
belongs_to :product, SimpleshopTheme.Products.Product
|
||||
@ -28,7 +29,7 @@ defmodule SimpleshopTheme.Products.ProductImage do
|
||||
"""
|
||||
def changeset(product_image, attrs) do
|
||||
product_image
|
||||
|> cast(attrs, [:product_id, :src, :position, :alt, :image_id])
|
||||
|> cast(attrs, [:product_id, :src, :position, :alt, :color, :image_id])
|
||||
|> validate_required([:product_id, :src])
|
||||
|> foreign_key_constraint(:product_id)
|
||||
|> foreign_key_constraint(:image_id)
|
||||
|
||||
@ -300,6 +300,12 @@ defmodule SimpleshopTheme.Providers.Printful do
|
||||
catalog_product_id = extract_catalog_product_id_from_variants(sync_variants)
|
||||
catalog_variant_ids = Enum.map(sync_variants, & &1["variant_id"]) |> Enum.reject(&is_nil/1)
|
||||
|
||||
color_variant_map =
|
||||
sync_variants
|
||||
|> Enum.reject(fn sv -> is_nil(sv["color"]) end)
|
||||
|> Enum.uniq_by(fn sv -> sv["color"] end)
|
||||
|> Map.new(fn sv -> {normalize_text(sv["color"]), sv["variant_id"]} end)
|
||||
|
||||
%{
|
||||
provider_product_id: to_string(sync_product["id"]),
|
||||
title: sync_product["name"],
|
||||
@ -310,6 +316,7 @@ defmodule SimpleshopTheme.Providers.Printful do
|
||||
provider_data: %{
|
||||
catalog_product_id: catalog_product_id,
|
||||
catalog_variant_ids: catalog_variant_ids,
|
||||
color_variant_map: color_variant_map,
|
||||
# Shipping calc uses these generic keys (shared with Printify)
|
||||
blueprint_id: catalog_product_id,
|
||||
print_provider_id: 0,
|
||||
@ -335,14 +342,14 @@ defmodule SimpleshopTheme.Providers.Printful do
|
||||
end
|
||||
|
||||
defp build_variant_title(sv) do
|
||||
parts = [sv["color"], sv["size"]] |> Enum.reject(&is_nil/1)
|
||||
parts = [sv["color"], sv["size"]] |> Enum.reject(&is_nil/1) |> Enum.map(&normalize_text/1)
|
||||
Enum.join(parts, " / ")
|
||||
end
|
||||
|
||||
defp build_variant_options(sv) do
|
||||
opts = %{}
|
||||
opts = if sv["color"], do: Map.put(opts, "Color", sv["color"]), else: opts
|
||||
opts = if sv["size"], do: Map.put(opts, "Size", sv["size"]), else: opts
|
||||
opts = if sv["color"], do: Map.put(opts, "Color", normalize_text(sv["color"])), else: opts
|
||||
opts = if sv["size"], do: Map.put(opts, "Size", normalize_text(sv["size"])), else: opts
|
||||
opts
|
||||
end
|
||||
|
||||
@ -364,7 +371,8 @@ defmodule SimpleshopTheme.Providers.Printful do
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {img, index} ->
|
||||
alt = if img.color not in [nil, ""], do: img.color, else: img.name
|
||||
%{src: img.src, position: index, alt: alt}
|
||||
color = if img.color not in [nil, ""], do: normalize_text(img.color), else: nil
|
||||
%{src: img.src, position: index, alt: alt, color: color}
|
||||
end)
|
||||
end
|
||||
|
||||
@ -389,17 +397,21 @@ defmodule SimpleshopTheme.Providers.Printful do
|
||||
|
||||
# Build option types from variants for frontend display
|
||||
defp build_option_types(sync_variants) do
|
||||
# Build colour values with hex codes from sync variant data
|
||||
colors =
|
||||
sync_variants
|
||||
|> Enum.map(& &1["color"])
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.uniq()
|
||||
|> Enum.map(fn color -> %{"title" => color} end)
|
||||
|> Enum.reject(fn sv -> is_nil(sv["color"]) end)
|
||||
|> Enum.uniq_by(fn sv -> sv["color"] end)
|
||||
|> Enum.map(fn sv ->
|
||||
base = %{"title" => normalize_text(sv["color"])}
|
||||
if sv["color_code"], do: Map.put(base, "hex", sv["color_code"]), else: base
|
||||
end)
|
||||
|
||||
sizes =
|
||||
sync_variants
|
||||
|> Enum.map(& &1["size"])
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.map(&normalize_text/1)
|
||||
|> Enum.uniq()
|
||||
|> Enum.map(fn size -> %{"title" => size} end)
|
||||
|
||||
@ -609,6 +621,17 @@ defmodule SimpleshopTheme.Providers.Printful do
|
||||
defp parse_int(value) when is_integer(value), do: value
|
||||
defp parse_int(value) when is_binary(value), do: String.to_integer(value)
|
||||
|
||||
# Printful uses Unicode double prime (″) and multiplication sign (×) in size
|
||||
# labels. These break LiveView's phx-value-* attribute serialization, so we
|
||||
# strip inch marks and normalise the multiplication sign.
|
||||
defp normalize_text(text) when is_binary(text) do
|
||||
text
|
||||
|> String.replace("″", "")
|
||||
|> String.replace("×", "x")
|
||||
end
|
||||
|
||||
defp normalize_text(text), do: text
|
||||
|
||||
defp set_credentials(api_key, store_id) do
|
||||
Process.put(:printful_api_key, api_key)
|
||||
if store_id, do: Process.put(:printful_store_id, store_id)
|
||||
|
||||
@ -336,14 +336,16 @@ defmodule SimpleshopTheme.Providers.Printify do
|
||||
|
||||
defp normalize_product(raw) do
|
||||
options = raw["options"] || []
|
||||
raw_variants = raw["variants"] || []
|
||||
color_lookup = build_image_color_lookup(raw_variants, options)
|
||||
|
||||
%{
|
||||
provider_product_id: to_string(raw["id"]),
|
||||
title: raw["title"],
|
||||
description: raw["description"],
|
||||
category: extract_category(raw),
|
||||
images: normalize_images(raw["images"] || []),
|
||||
variants: normalize_variants(raw["variants"] || [], options),
|
||||
images: normalize_images(raw["images"] || [], color_lookup),
|
||||
variants: normalize_variants(raw_variants, options),
|
||||
provider_data: %{
|
||||
blueprint_id: raw["blueprint_id"],
|
||||
print_provider_id: raw["print_provider_id"],
|
||||
@ -354,20 +356,51 @@ defmodule SimpleshopTheme.Providers.Printify do
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_images(images) do
|
||||
defp normalize_images(images, color_lookup) do
|
||||
images
|
||||
|> Enum.map(fn img ->
|
||||
color = resolve_image_color(img["variant_ids"] || [], color_lookup)
|
||||
%{src: img["src"], alt: img["position"], color: color, raw_position: img["position"]}
|
||||
end)
|
||||
|> select_images_per_color()
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {img, index} ->
|
||||
# Printify returns position as a label string (e.g., "front", "back")
|
||||
# We use the index as the numeric position instead
|
||||
%{
|
||||
src: img["src"],
|
||||
position: index,
|
||||
alt: img["position"]
|
||||
}
|
||||
%{src: img.src, position: index, alt: img.alt, color: img.color}
|
||||
end)
|
||||
end
|
||||
|
||||
# Hero colour (first seen) keeps all images.
|
||||
# Other colours keep only front + back views to avoid bloating the DB.
|
||||
defp select_images_per_color(images) do
|
||||
hero_color =
|
||||
images
|
||||
|> Enum.find_value(fn img -> img.color end)
|
||||
|
||||
Enum.filter(images, fn img ->
|
||||
img.color == hero_color || is_nil(img.color) ||
|
||||
img.raw_position in ["front", "back"]
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_image_color_lookup(raw_variants, options) do
|
||||
option_names = Enum.map(options, & &1["name"])
|
||||
color_index = Enum.find_index(option_names, &(&1 in ["Colors", "Color"]))
|
||||
|
||||
if color_index do
|
||||
Map.new(raw_variants, fn var ->
|
||||
parts = String.split(var["title"] || "", " / ")
|
||||
color = Enum.at(parts, color_index)
|
||||
{var["id"], color}
|
||||
end)
|
||||
else
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_image_color(variant_ids, color_lookup) do
|
||||
Enum.find_value(variant_ids, fn vid -> Map.get(color_lookup, vid) end)
|
||||
end
|
||||
|
||||
defp normalize_variants(variants, options) do
|
||||
option_names = extract_option_names(options)
|
||||
|
||||
|
||||
@ -6,6 +6,9 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
This worker uses the legacy mockup generator API to produce extra angles
|
||||
(back, left, right, etc.) and appends them as additional product images.
|
||||
|
||||
The hero colour gets full angle coverage (front, back, left, right).
|
||||
Other colours get a front-view mockup only (one API call each).
|
||||
|
||||
Each product is processed as a separate job so failures don't block others.
|
||||
The temporary S3 URLs from the mockup generator are downloaded via the
|
||||
existing ImageDownloadWorker pipeline.
|
||||
@ -22,6 +25,7 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
|
||||
@poll_interval_ms 3_000
|
||||
@max_poll_attempts 20
|
||||
@inter_color_delay_ms 5_000
|
||||
|
||||
# Mockup generator config per catalog product type:
|
||||
# {placement, area_width, area_height}
|
||||
@ -78,8 +82,8 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
|
||||
artwork_url = provider_data["artwork_url"] || provider_data[:artwork_url]
|
||||
|
||||
catalog_variant_ids =
|
||||
provider_data["catalog_variant_ids"] || provider_data[:catalog_variant_ids] || []
|
||||
color_variant_map =
|
||||
provider_data["color_variant_map"] || provider_data[:color_variant_map] || %{}
|
||||
|
||||
cond do
|
||||
is_nil(catalog_product_id) ->
|
||||
@ -95,13 +99,40 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
:ok
|
||||
|
||||
true ->
|
||||
# Pick one representative variant for mockup generation
|
||||
variant_id = List.first(catalog_variant_ids)
|
||||
enrich_all_colours(product, catalog_product_id, artwork_url, color_variant_map)
|
||||
end
|
||||
end
|
||||
|
||||
case generate_and_append(product, catalog_product_id, variant_id, artwork_url) do
|
||||
{:ok, count} ->
|
||||
Logger.info("[MockupEnricher] Added #{count} extra angle(s) for #{product.title}")
|
||||
defp enrich_all_colours(product, catalog_product_id, artwork_url, color_variant_map) do
|
||||
colors = Map.to_list(color_variant_map)
|
||||
|
||||
if colors == [] do
|
||||
# No colour info — fall back to single-variant enrichment
|
||||
Logger.info(
|
||||
"[MockupEnricher] No color_variant_map for #{product.title}, using first variant"
|
||||
)
|
||||
|
||||
enrich_single_colour(product, catalog_product_id, nil, nil, artwork_url, :hero)
|
||||
else
|
||||
# First colour is the hero (gets full angles), rest get front-only
|
||||
[{hero_color, hero_variant_id} | other_colors] = colors
|
||||
|
||||
case enrich_single_colour(
|
||||
product,
|
||||
catalog_product_id,
|
||||
hero_color,
|
||||
hero_variant_id,
|
||||
artwork_url,
|
||||
:hero
|
||||
) do
|
||||
{:ok, hero_count} ->
|
||||
Logger.info("[MockupEnricher] Hero colour #{hero_color}: #{hero_count} image(s)")
|
||||
|
||||
other_count =
|
||||
enrich_other_colours(product, catalog_product_id, artwork_url, other_colors)
|
||||
|
||||
total = hero_count + other_count
|
||||
Logger.info("[MockupEnricher] Total #{total} extra image(s) for #{product.title}")
|
||||
:ok
|
||||
|
||||
{:error, {429, _}} ->
|
||||
@ -109,19 +140,55 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
{:snooze, 60}
|
||||
|
||||
{:error, {400, _} = reason} ->
|
||||
# Permanent API error (wrong placement, unsupported product, etc.)
|
||||
Logger.info("[MockupEnricher] #{product.title} not supported: #{inspect(reason)}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("[MockupEnricher] Failed for #{product.title}: #{inspect(reason)}")
|
||||
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_and_append(product, catalog_product_id, variant_id, artwork_url) do
|
||||
defp enrich_other_colours(product, catalog_product_id, artwork_url, colors) do
|
||||
Enum.reduce(colors, 0, fn {color_name, variant_id}, acc ->
|
||||
Process.sleep(@inter_color_delay_ms)
|
||||
|
||||
case enrich_single_colour(
|
||||
product,
|
||||
catalog_product_id,
|
||||
color_name,
|
||||
variant_id,
|
||||
artwork_url,
|
||||
:front_only
|
||||
) do
|
||||
{:ok, count} ->
|
||||
Logger.info("[MockupEnricher] Colour #{color_name}: #{count} image(s)")
|
||||
acc + count
|
||||
|
||||
{:error, {429, _}} ->
|
||||
# Rate limited on a non-hero colour — log and continue with remaining
|
||||
Logger.info(
|
||||
"[MockupEnricher] Rate limited on #{color_name}, skipping remaining colours"
|
||||
)
|
||||
|
||||
acc
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("[MockupEnricher] Failed for colour #{color_name}: #{inspect(reason)}")
|
||||
acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp enrich_single_colour(
|
||||
product,
|
||||
catalog_product_id,
|
||||
color_name,
|
||||
variant_id,
|
||||
artwork_url,
|
||||
mode
|
||||
) do
|
||||
{placement, area_width, area_height} =
|
||||
Map.get(@product_configs, catalog_product_id, {"front", 4500, 5100})
|
||||
|
||||
@ -146,12 +213,13 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
|
||||
with {:ok, task_data} <- Client.create_mockup_generator_task(catalog_product_id, body),
|
||||
task_key <- task_data["task_key"],
|
||||
{:ok, result} <- poll_generator_task(task_key),
|
||||
extra_images <- extract_extra_images(result) do
|
||||
if extra_images == [] do
|
||||
{:ok, result} <- poll_generator_task(task_key) do
|
||||
images = extract_images(result, mode)
|
||||
|
||||
if images == [] do
|
||||
{:ok, 0}
|
||||
else
|
||||
append_images_to_product(product, extra_images)
|
||||
append_images_to_product(product, images, color_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -176,7 +244,6 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
poll_generator_task(task_key, attempt + 1)
|
||||
|
||||
{:error, {429, _}} ->
|
||||
# Rate limited — back off and retry
|
||||
Process.sleep(60_000)
|
||||
poll_generator_task(task_key, attempt + 1)
|
||||
|
||||
@ -185,55 +252,42 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
end
|
||||
end
|
||||
|
||||
# Collect all extra angle URLs from the mockup generator response.
|
||||
# The "extra" array contains alternate views (back, left, right, etc.)
|
||||
defp extract_extra_images(result) do
|
||||
# Hero mode: collect all extra angle images (front, back, left, right, etc.)
|
||||
# Front-only mode: just the main mockup URL
|
||||
defp extract_images(result, :hero) do
|
||||
(result["mockups"] || [])
|
||||
|> Enum.flat_map(fn mockup ->
|
||||
(mockup["extra"] || [])
|
||||
|> Enum.map(fn extra ->
|
||||
%{
|
||||
src: extra["url"],
|
||||
alt: extra["title"]
|
||||
}
|
||||
%{src: extra["url"], alt: extra["title"]}
|
||||
end)
|
||||
end)
|
||||
|> Enum.reject(&is_nil(&1.src))
|
||||
|> Enum.uniq_by(& &1.src)
|
||||
end
|
||||
|
||||
defp append_images_to_product(product, extra_images) do
|
||||
existing_images = Products.list_product_images(product.id)
|
||||
existing_count = length(existing_images)
|
||||
|
||||
# Sort extras: front views first, then the rest
|
||||
sorted_extras =
|
||||
Enum.sort_by(extra_images, fn img ->
|
||||
title = String.downcase(img.alt || "")
|
||||
if String.contains?(title, "front"), do: 0, else: 1
|
||||
defp extract_images(result, :front_only) do
|
||||
(result["mockups"] || [])
|
||||
|> Enum.take(1)
|
||||
|> Enum.map(fn mockup ->
|
||||
%{src: mockup["mockup_url"], alt: "Front"}
|
||||
end)
|
||||
|
||||
has_front_extra =
|
||||
Enum.any?(sorted_extras, fn img ->
|
||||
String.contains?(String.downcase(img.alt || ""), "front")
|
||||
end)
|
||||
|
||||
# If we have a front extra, shift existing images down to make room
|
||||
if has_front_extra do
|
||||
shift_images_down(existing_images, length(sorted_extras))
|
||||
|> Enum.reject(&is_nil(&1.src))
|
||||
end
|
||||
|
||||
# Insert extras: front extras at position 0+, others after existing
|
||||
start_position = if has_front_extra, do: 0, else: existing_count
|
||||
defp append_images_to_product(product, extra_images, color_name) do
|
||||
existing_images = Products.list_product_images(product.id)
|
||||
next_position = max_position(existing_images) + 1
|
||||
|
||||
results =
|
||||
sorted_extras
|
||||
|> Enum.with_index(start_position)
|
||||
extra_images
|
||||
|> Enum.with_index(next_position)
|
||||
|> Enum.map(fn {img, position} ->
|
||||
attrs = %{
|
||||
product_id: product.id,
|
||||
src: img.src,
|
||||
alt: img.alt,
|
||||
color: color_name,
|
||||
position: position
|
||||
}
|
||||
|
||||
@ -252,17 +306,19 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
|
||||
{:ok, count}
|
||||
end
|
||||
|
||||
# Bump all existing image positions up by `offset` to make room at the front
|
||||
defp shift_images_down(existing_images, offset) do
|
||||
Enum.each(existing_images, fn img ->
|
||||
Products.update_product_image(img, %{position: img.position + offset})
|
||||
end)
|
||||
defp max_position([]), do: -1
|
||||
|
||||
defp max_position(images) do
|
||||
images |> Enum.map(& &1.position) |> Enum.max()
|
||||
end
|
||||
|
||||
# Check if this product already has extra angle images from a prior enrichment
|
||||
# Check if this product already has mockup-enriched images (those with a color tag)
|
||||
defp already_enriched?(product) do
|
||||
images = Products.list_product_images(product.id)
|
||||
Enum.any?(images, fn img -> img.alt in ["Front", "Back", "Left", "Right"] end)
|
||||
|
||||
Enum.any?(images, fn img ->
|
||||
img.color != nil && img.alt in ["Front", "Back", "Left", "Right"]
|
||||
end)
|
||||
end
|
||||
|
||||
defp set_credentials(conn) do
|
||||
|
||||
@ -176,7 +176,8 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
||||
%{
|
||||
src: img[:src],
|
||||
position: img[:position],
|
||||
alt: img[:alt]
|
||||
alt: img[:alt],
|
||||
color: img[:color]
|
||||
}
|
||||
end)
|
||||
|
||||
|
||||
@ -1530,23 +1530,18 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
~H"""
|
||||
<button
|
||||
type="button"
|
||||
phx-click={if @mode == :shop and not @disabled, do: "select_option"}
|
||||
phx-click={if @mode == :shop, do: "select_option"}
|
||||
phx-value-option={@option_name}
|
||||
phx-value-value={@title}
|
||||
phx-value-selected={@title}
|
||||
class={[
|
||||
"w-10 h-10 rounded-full border-2 transition-all relative",
|
||||
@selected && "ring-2 ring-offset-2",
|
||||
@disabled && "opacity-40 cursor-not-allowed"
|
||||
@selected && "ring-2 ring-offset-2"
|
||||
]}
|
||||
style={"background-color: #{@hex}; border-color: #{if @selected, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l))", else: "var(--t-border-default)"}; --tw-ring-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));"}
|
||||
title={@title}
|
||||
disabled={@disabled}
|
||||
aria-label={"Select #{@title}"}
|
||||
aria-pressed={@selected}
|
||||
aria-pressed={to_string(@selected)}
|
||||
>
|
||||
<span :if={@disabled} class="absolute inset-0 flex items-center justify-center">
|
||||
<span class="w-full h-0.5 bg-gray-400 rotate-45 absolute"></span>
|
||||
</span>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
@ -1561,16 +1556,14 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
~H"""
|
||||
<button
|
||||
type="button"
|
||||
phx-click={if @mode == :shop and not @disabled, do: "select_option"}
|
||||
phx-click={if @mode == :shop, do: "select_option"}
|
||||
phx-value-option={@option_name}
|
||||
phx-value-value={@title}
|
||||
phx-value-selected={@title}
|
||||
class={[
|
||||
"px-4 py-2 font-medium transition-all",
|
||||
@disabled && "opacity-40 cursor-not-allowed line-through"
|
||||
"px-4 py-2 font-medium transition-all"
|
||||
]}
|
||||
style={"border: 2px solid #{if @selected, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l))", else: "var(--t-border-default)"}; border-radius: var(--t-radius-button); color: var(--t-text-primary); background: #{if @selected, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.1)", else: "transparent"};"}
|
||||
disabled={@disabled}
|
||||
aria-pressed={@selected}
|
||||
aria-pressed={to_string(@selected)}
|
||||
>
|
||||
{@title}
|
||||
</button>
|
||||
|
||||
@ -19,22 +19,26 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
|
||||
exclude: product.id
|
||||
)
|
||||
|
||||
gallery_images =
|
||||
all_images =
|
||||
(product.images || [])
|
||||
|> Enum.sort_by(& &1.position)
|
||||
|> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.map(fn img ->
|
||||
%{url: ProductImage.direct_url(img, 1200), color: img.color}
|
||||
end)
|
||||
|> Enum.reject(fn img -> is_nil(img.url) end)
|
||||
|
||||
option_types = Product.option_types(product)
|
||||
variants = product.variants || []
|
||||
{selected_options, selected_variant} = initialize_variant_selection(variants)
|
||||
available_options = compute_available_options(option_types, variants, selected_options)
|
||||
display_price = variant_price(selected_variant, product)
|
||||
gallery_images = filter_gallery_images(all_images, selected_options["Color"])
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, product.title)
|
||||
|> assign(:product, product)
|
||||
|> assign(:all_images, all_images)
|
||||
|> assign(:gallery_images, gallery_images)
|
||||
|> assign(:related_products, related_products)
|
||||
|> assign(:quantity, 1)
|
||||
@ -80,22 +84,61 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
|
||||
defp variant_price(_, %{cheapest_price: price}), do: price
|
||||
defp variant_price(_, _), do: 0
|
||||
|
||||
# If the current combo doesn't match any variant, auto-adjust other options
|
||||
# to find a valid one. Keeps the just-changed option fixed, adjusts the rest.
|
||||
defp resolve_valid_combo(variants, option_types, selected_options, changed_option) do
|
||||
if Enum.any?(variants, fn v -> v.options == selected_options end) do
|
||||
selected_options
|
||||
else
|
||||
matching =
|
||||
Enum.filter(variants, fn v ->
|
||||
v.is_available && v.options[changed_option] == selected_options[changed_option]
|
||||
end)
|
||||
|
||||
case matching do
|
||||
[first | _] ->
|
||||
Enum.reduce(option_types, selected_options, fn opt_type, acc ->
|
||||
if opt_type.name == changed_option do
|
||||
acc
|
||||
else
|
||||
Map.put(acc, opt_type.name, first.options[opt_type.name])
|
||||
end
|
||||
end)
|
||||
|
||||
[] ->
|
||||
selected_options
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp find_variant(variants, selected_options) do
|
||||
Enum.find(variants, fn v -> v.options == selected_options end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("select_option", %{"option" => option_name, "value" => value}, socket) do
|
||||
selected_options = Map.put(socket.assigns.selected_options, option_name, value)
|
||||
defp filter_gallery_images(all_images, selected_color) do
|
||||
if selected_color do
|
||||
color_images = Enum.filter(all_images, &(&1.color == selected_color))
|
||||
if color_images == [], do: all_images, else: color_images
|
||||
else
|
||||
all_images
|
||||
end
|
||||
|> Enum.map(& &1.url)
|
||||
end
|
||||
|
||||
selected_variant = find_variant(socket.assigns.variants, selected_options)
|
||||
@impl true
|
||||
def handle_event("select_option", %{"option" => option_name, "selected" => value}, socket) do
|
||||
variants = socket.assigns.variants
|
||||
option_types = socket.assigns.option_types
|
||||
|
||||
selected_options = Map.put(socket.assigns.selected_options, option_name, value)
|
||||
selected_options = resolve_valid_combo(variants, option_types, selected_options, option_name)
|
||||
|
||||
selected_variant = find_variant(variants, selected_options)
|
||||
|
||||
available_options =
|
||||
compute_available_options(
|
||||
socket.assigns.option_types,
|
||||
socket.assigns.variants,
|
||||
selected_options
|
||||
)
|
||||
compute_available_options(option_types, variants, selected_options)
|
||||
|
||||
gallery_images = filter_gallery_images(socket.assigns.all_images, selected_options["Color"])
|
||||
|
||||
socket =
|
||||
socket
|
||||
@ -103,6 +146,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
|
||||
|> assign(:selected_variant, selected_variant)
|
||||
|> assign(:available_options, available_options)
|
||||
|> assign(:display_price, variant_price(selected_variant, socket.assigns.product))
|
||||
|> assign(:gallery_images, gallery_images)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
defmodule SimpleshopTheme.Repo.Migrations.AddColorToProductImages do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:product_images) do
|
||||
add :color, :string
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -216,22 +216,23 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("button[phx-value-value='18x24']")
|
||||
|> element("button[phx-value-selected='18x24']")
|
||||
|> render_click()
|
||||
|
||||
assert html =~ "£32.00"
|
||||
end
|
||||
|
||||
test "selecting a colour updates available sizes", %{conn: conn, shirt: shirt} do
|
||||
test "selecting a colour auto-adjusts size if needed", %{conn: conn, shirt: shirt} do
|
||||
{:ok, view, _html} = live(conn, ~p"/products/#{shirt.slug}")
|
||||
|
||||
# Select White — M and L are available, XL is not
|
||||
html =
|
||||
view
|
||||
|> element("button[aria-label='Select White']")
|
||||
|> render_click()
|
||||
|
||||
# XL should be disabled (unavailable in white)
|
||||
assert html =~ "disabled"
|
||||
# White is selected, size M should still be selected (valid combo)
|
||||
assert html =~ ~s(aria-pressed="true")
|
||||
end
|
||||
|
||||
test "shows variant for single-variant product", %{conn: conn, related: related} do
|
||||
|
||||
Loading…
Reference in New Issue
Block a user