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:
jamey 2026-02-15 23:21:22 +00:00
parent 29d8839ac2
commit daa6d3de71
13 changed files with 375 additions and 146 deletions

View File

@ -56,6 +56,24 @@ defmodule SimpleshopTheme.Clients.Printify do
end end
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 """ @doc """
Make a DELETE request to the Printify API. 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}") get("/shops/#{shop_id}/products.json?limit=#{limit}&page=#{page}")
end 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 """ @doc """
Delete a product from a shop. Delete a product from a shop.
""" """

View File

@ -66,7 +66,8 @@ defmodule SimpleshopTheme.Mockups.Generator do
category: "Apparel", category: "Apparel",
artwork_url: unsplash_download_url("EhvMzMRO4_o"), artwork_url: unsplash_download_url("EhvMzMRO4_o"),
product_type: :tshirt, product_type: :tshirt,
price: 2999 price: 2999,
colors: ["Black", "White", "Sport Grey", "Forest Green"]
}, },
%{ %{
name: "Forest Light Hoodie", name: "Forest Light Hoodie",
@ -74,7 +75,8 @@ defmodule SimpleshopTheme.Mockups.Generator do
category: "Apparel", category: "Apparel",
artwork_url: unsplash_download_url("FwVkxITt8Bg"), artwork_url: unsplash_download_url("FwVkxITt8Bg"),
product_type: :hoodie, product_type: :hoodie,
price: 4499 price: 4499,
colors: ["Dark Heather", "Navy", "Forest Green", "Sand"]
}, },
%{ %{
name: "Wildflower Meadow Tote Bag", name: "Wildflower Meadow Tote Bag",
@ -275,6 +277,10 @@ defmodule SimpleshopTheme.Mockups.Generator do
@doc """ @doc """
Create a product with the uploaded artwork. 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( def create_product(
shop_id, shop_id,
@ -286,17 +292,17 @@ defmodule SimpleshopTheme.Mockups.Generator do
print_provider_id, print_provider_id,
variants variants
) do ) do
# Get the first variant for simplicity (typically a standard size/color) selected_variants = select_variants(variants, product_def)
variant = hd(variants)
variant_id = variant["id"]
# 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"] || [] placeholders = variant["placeholders"] || []
front_placeholder = front_placeholder =
Enum.find(placeholders, fn p -> p["position"] == "front" end) || hd(placeholders) Enum.find(placeholders, fn p -> p["position"] == "front" end) || hd(placeholders)
# Extract placeholder dimensions and calculate cover scale
placeholder_width = front_placeholder["width"] placeholder_width = front_placeholder["width"]
placeholder_height = front_placeholder["height"] 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)}" " 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 = %{ product_data = %{
title: product_def.name, title: product_def.name,
description: "#{product_def.name} - Nature-inspired design from Wildprint Studio", description: "#{product_def.name} - Nature-inspired design from Wildprint Studio",
blueprint_id: blueprint_id, blueprint_id: blueprint_id,
print_provider_id: print_provider_id, print_provider_id: print_provider_id,
variants: [ variants:
%{ Enum.map(selected_variants, fn v ->
id: variant_id, %{id: v["id"], price: product_def.price, is_enabled: true}
price: product_def.price, end),
is_enabled: true
}
],
print_areas: [ print_areas: [
%{ %{
variant_ids: [variant_id], variant_ids: variant_ids,
placeholders: [ placeholders: [
%{ %{
position: front_placeholder["position"] || "front", position: front_placeholder["position"] || "front",
images: [ 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) Client.create_product(shop_id, product_data)
end 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 """ @doc """
Extract mockup image URLs from a created product. Extract mockup image URLs from a created product.
""" """

View File

@ -525,14 +525,25 @@ defmodule SimpleshopTheme.Products do
existing = Map.get(existing_by_position, position) existing = Map.get(existing_by_position, position)
cond do 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 -> existing && existing.src == src ->
{:ok, existing} 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) # Different URL at position - update src, clear image_id (triggers re-download)
existing -> existing ->
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() |> Repo.update()
# New position - create new # New position - create new

View File

@ -119,7 +119,7 @@ defmodule SimpleshopTheme.Products.Product do
end end
end) end)
%{name: opt["name"], type: type, values: values} %{name: singularize_option_name(opt["name"]), type: type, values: values}
end) end)
end end
@ -129,6 +129,12 @@ defmodule SimpleshopTheme.Products.Product do
defp option_type_atom("color"), do: :color defp option_type_atom("color"), do: :color
defp option_type_atom(_), do: :size 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 """ @doc """
Generates a checksum from provider data for detecting changes. Generates a checksum from provider data for detecting changes.
""" """

View File

@ -15,6 +15,7 @@ defmodule SimpleshopTheme.Products.ProductImage do
field :src, :string field :src, :string
field :position, :integer, default: 0 field :position, :integer, default: 0
field :alt, :string field :alt, :string
field :color, :string
field :image_id, :binary_id field :image_id, :binary_id
belongs_to :product, SimpleshopTheme.Products.Product belongs_to :product, SimpleshopTheme.Products.Product
@ -28,7 +29,7 @@ defmodule SimpleshopTheme.Products.ProductImage do
""" """
def changeset(product_image, attrs) do def changeset(product_image, attrs) do
product_image 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]) |> validate_required([:product_id, :src])
|> foreign_key_constraint(:product_id) |> foreign_key_constraint(:product_id)
|> foreign_key_constraint(:image_id) |> foreign_key_constraint(:image_id)

View File

@ -300,6 +300,12 @@ defmodule SimpleshopTheme.Providers.Printful do
catalog_product_id = extract_catalog_product_id_from_variants(sync_variants) 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) 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"]), provider_product_id: to_string(sync_product["id"]),
title: sync_product["name"], title: sync_product["name"],
@ -310,6 +316,7 @@ defmodule SimpleshopTheme.Providers.Printful do
provider_data: %{ provider_data: %{
catalog_product_id: catalog_product_id, catalog_product_id: catalog_product_id,
catalog_variant_ids: catalog_variant_ids, catalog_variant_ids: catalog_variant_ids,
color_variant_map: color_variant_map,
# Shipping calc uses these generic keys (shared with Printify) # Shipping calc uses these generic keys (shared with Printify)
blueprint_id: catalog_product_id, blueprint_id: catalog_product_id,
print_provider_id: 0, print_provider_id: 0,
@ -335,14 +342,14 @@ defmodule SimpleshopTheme.Providers.Printful do
end end
defp build_variant_title(sv) do 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, " / ") Enum.join(parts, " / ")
end end
defp build_variant_options(sv) do defp build_variant_options(sv) do
opts = %{} opts = %{}
opts = if sv["color"], do: Map.put(opts, "Color", sv["color"]), 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", sv["size"]), else: opts opts = if sv["size"], do: Map.put(opts, "Size", normalize_text(sv["size"])), else: opts
opts opts
end end
@ -364,7 +371,8 @@ defmodule SimpleshopTheme.Providers.Printful do
|> Enum.with_index() |> Enum.with_index()
|> Enum.map(fn {img, index} -> |> Enum.map(fn {img, index} ->
alt = if img.color not in [nil, ""], do: img.color, else: img.name 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)
end end
@ -389,17 +397,21 @@ defmodule SimpleshopTheme.Providers.Printful do
# Build option types from variants for frontend display # Build option types from variants for frontend display
defp build_option_types(sync_variants) do defp build_option_types(sync_variants) do
# Build colour values with hex codes from sync variant data
colors = colors =
sync_variants sync_variants
|> Enum.map(& &1["color"]) |> Enum.reject(fn sv -> is_nil(sv["color"]) end)
|> Enum.reject(&is_nil/1) |> Enum.uniq_by(fn sv -> sv["color"] end)
|> Enum.uniq() |> Enum.map(fn sv ->
|> Enum.map(fn color -> %{"title" => color} end) base = %{"title" => normalize_text(sv["color"])}
if sv["color_code"], do: Map.put(base, "hex", sv["color_code"]), else: base
end)
sizes = sizes =
sync_variants sync_variants
|> Enum.map(& &1["size"]) |> Enum.map(& &1["size"])
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
|> Enum.map(&normalize_text/1)
|> Enum.uniq() |> Enum.uniq()
|> Enum.map(fn size -> %{"title" => size} end) |> 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_integer(value), do: value
defp parse_int(value) when is_binary(value), do: String.to_integer(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 defp set_credentials(api_key, store_id) do
Process.put(:printful_api_key, api_key) Process.put(:printful_api_key, api_key)
if store_id, do: Process.put(:printful_store_id, store_id) if store_id, do: Process.put(:printful_store_id, store_id)

View File

@ -336,14 +336,16 @@ defmodule SimpleshopTheme.Providers.Printify do
defp normalize_product(raw) do defp normalize_product(raw) do
options = raw["options"] || [] options = raw["options"] || []
raw_variants = raw["variants"] || []
color_lookup = build_image_color_lookup(raw_variants, options)
%{ %{
provider_product_id: to_string(raw["id"]), provider_product_id: to_string(raw["id"]),
title: raw["title"], title: raw["title"],
description: raw["description"], description: raw["description"],
category: extract_category(raw), category: extract_category(raw),
images: normalize_images(raw["images"] || []), images: normalize_images(raw["images"] || [], color_lookup),
variants: normalize_variants(raw["variants"] || [], options), variants: normalize_variants(raw_variants, options),
provider_data: %{ provider_data: %{
blueprint_id: raw["blueprint_id"], blueprint_id: raw["blueprint_id"],
print_provider_id: raw["print_provider_id"], print_provider_id: raw["print_provider_id"],
@ -354,20 +356,51 @@ defmodule SimpleshopTheme.Providers.Printify do
} }
end end
defp normalize_images(images) do defp normalize_images(images, color_lookup) do
images 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.with_index()
|> Enum.map(fn {img, index} -> |> Enum.map(fn {img, index} ->
# Printify returns position as a label string (e.g., "front", "back") %{src: img.src, position: index, alt: img.alt, color: img.color}
# We use the index as the numeric position instead
%{
src: img["src"],
position: index,
alt: img["position"]
}
end) end)
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 defp normalize_variants(variants, options) do
option_names = extract_option_names(options) option_names = extract_option_names(options)

View File

@ -6,6 +6,9 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
This worker uses the legacy mockup generator API to produce extra angles This worker uses the legacy mockup generator API to produce extra angles
(back, left, right, etc.) and appends them as additional product images. (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. 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 The temporary S3 URLs from the mockup generator are downloaded via the
existing ImageDownloadWorker pipeline. existing ImageDownloadWorker pipeline.
@ -22,6 +25,7 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
@poll_interval_ms 3_000 @poll_interval_ms 3_000
@max_poll_attempts 20 @max_poll_attempts 20
@inter_color_delay_ms 5_000
# Mockup generator config per catalog product type: # Mockup generator config per catalog product type:
# {placement, area_width, area_height} # {placement, area_width, area_height}
@ -78,8 +82,8 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
artwork_url = provider_data["artwork_url"] || provider_data[:artwork_url] artwork_url = provider_data["artwork_url"] || provider_data[:artwork_url]
catalog_variant_ids = color_variant_map =
provider_data["catalog_variant_ids"] || provider_data[:catalog_variant_ids] || [] provider_data["color_variant_map"] || provider_data[:color_variant_map] || %{}
cond do cond do
is_nil(catalog_product_id) -> is_nil(catalog_product_id) ->
@ -95,33 +99,96 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
:ok :ok
true -> true ->
# Pick one representative variant for mockup generation enrich_all_colours(product, catalog_product_id, artwork_url, color_variant_map)
variant_id = List.first(catalog_variant_ids)
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}")
:ok
{:error, {429, _}} ->
Logger.info("[MockupEnricher] Rate limited for #{product.title}, snoozing 60s")
{: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
end end
defp generate_and_append(product, catalog_product_id, variant_id, artwork_url) do 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, _}} ->
Logger.info("[MockupEnricher] Rate limited for #{product.title}, snoozing 60s")
{:snooze, 60}
{:error, {400, _} = reason} ->
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 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} = {placement, area_width, area_height} =
Map.get(@product_configs, catalog_product_id, {"front", 4500, 5100}) 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), with {:ok, task_data} <- Client.create_mockup_generator_task(catalog_product_id, body),
task_key <- task_data["task_key"], task_key <- task_data["task_key"],
{:ok, result} <- poll_generator_task(task_key), {:ok, result} <- poll_generator_task(task_key) do
extra_images <- extract_extra_images(result) do images = extract_images(result, mode)
if extra_images == [] do
if images == [] do
{:ok, 0} {:ok, 0}
else else
append_images_to_product(product, extra_images) append_images_to_product(product, images, color_name)
end end
end end
end end
@ -176,7 +244,6 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
poll_generator_task(task_key, attempt + 1) poll_generator_task(task_key, attempt + 1)
{:error, {429, _}} -> {:error, {429, _}} ->
# Rate limited — back off and retry
Process.sleep(60_000) Process.sleep(60_000)
poll_generator_task(task_key, attempt + 1) poll_generator_task(task_key, attempt + 1)
@ -185,55 +252,42 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
end end
end end
# Collect all extra angle URLs from the mockup generator response. # Hero mode: collect all extra angle images (front, back, left, right, etc.)
# The "extra" array contains alternate views (back, left, right, etc.) # Front-only mode: just the main mockup URL
defp extract_extra_images(result) do defp extract_images(result, :hero) do
(result["mockups"] || []) (result["mockups"] || [])
|> Enum.flat_map(fn mockup -> |> Enum.flat_map(fn mockup ->
(mockup["extra"] || []) (mockup["extra"] || [])
|> Enum.map(fn extra -> |> Enum.map(fn extra ->
%{ %{src: extra["url"], alt: extra["title"]}
src: extra["url"],
alt: extra["title"]
}
end) end)
end) end)
|> Enum.reject(&is_nil(&1.src)) |> Enum.reject(&is_nil(&1.src))
|> Enum.uniq_by(& &1.src) |> Enum.uniq_by(& &1.src)
end end
defp append_images_to_product(product, extra_images) do defp extract_images(result, :front_only) do
(result["mockups"] || [])
|> Enum.take(1)
|> Enum.map(fn mockup ->
%{src: mockup["mockup_url"], alt: "Front"}
end)
|> Enum.reject(&is_nil(&1.src))
end
defp append_images_to_product(product, extra_images, color_name) do
existing_images = Products.list_product_images(product.id) existing_images = Products.list_product_images(product.id)
existing_count = length(existing_images) next_position = max_position(existing_images) + 1
# 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
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))
end
# Insert extras: front extras at position 0+, others after existing
start_position = if has_front_extra, do: 0, else: existing_count
results = results =
sorted_extras extra_images
|> Enum.with_index(start_position) |> Enum.with_index(next_position)
|> Enum.map(fn {img, position} -> |> Enum.map(fn {img, position} ->
attrs = %{ attrs = %{
product_id: product.id, product_id: product.id,
src: img.src, src: img.src,
alt: img.alt, alt: img.alt,
color: color_name,
position: position position: position
} }
@ -252,17 +306,19 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
{:ok, count} {:ok, count}
end end
# Bump all existing image positions up by `offset` to make room at the front defp max_position([]), do: -1
defp shift_images_down(existing_images, offset) do
Enum.each(existing_images, fn img -> defp max_position(images) do
Products.update_product_image(img, %{position: img.position + offset}) images |> Enum.map(& &1.position) |> Enum.max()
end)
end 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 defp already_enriched?(product) do
images = Products.list_product_images(product.id) 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 end
defp set_credentials(conn) do defp set_credentials(conn) do

View File

@ -176,7 +176,8 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
%{ %{
src: img[:src], src: img[:src],
position: img[:position], position: img[:position],
alt: img[:alt] alt: img[:alt],
color: img[:color]
} }
end) end)

View File

@ -1530,23 +1530,18 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
~H""" ~H"""
<button <button
type="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-option={@option_name}
phx-value-value={@title} phx-value-selected={@title}
class={[ class={[
"w-10 h-10 rounded-full border-2 transition-all relative", "w-10 h-10 rounded-full border-2 transition-all relative",
@selected && "ring-2 ring-offset-2", @selected && "ring-2 ring-offset-2"
@disabled && "opacity-40 cursor-not-allowed"
]} ]}
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));"} 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} title={@title}
disabled={@disabled}
aria-label={"Select #{@title}"} 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> </button>
""" """
end end
@ -1561,16 +1556,14 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
~H""" ~H"""
<button <button
type="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-option={@option_name}
phx-value-value={@title} phx-value-selected={@title}
class={[ class={[
"px-4 py-2 font-medium transition-all", "px-4 py-2 font-medium transition-all"
@disabled && "opacity-40 cursor-not-allowed line-through"
]} ]}
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"};"} 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={to_string(@selected)}
aria-pressed={@selected}
> >
{@title} {@title}
</button> </button>

View File

@ -19,22 +19,26 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
exclude: product.id exclude: product.id
) )
gallery_images = all_images =
(product.images || []) (product.images || [])
|> Enum.sort_by(& &1.position) |> Enum.sort_by(& &1.position)
|> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end) |> Enum.map(fn img ->
|> Enum.reject(&is_nil/1) %{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) option_types = Product.option_types(product)
variants = product.variants || [] variants = product.variants || []
{selected_options, selected_variant} = initialize_variant_selection(variants) {selected_options, selected_variant} = initialize_variant_selection(variants)
available_options = compute_available_options(option_types, variants, selected_options) available_options = compute_available_options(option_types, variants, selected_options)
display_price = variant_price(selected_variant, product) display_price = variant_price(selected_variant, product)
gallery_images = filter_gallery_images(all_images, selected_options["Color"])
socket = socket =
socket socket
|> assign(:page_title, product.title) |> assign(:page_title, product.title)
|> assign(:product, product) |> assign(:product, product)
|> assign(:all_images, all_images)
|> assign(:gallery_images, gallery_images) |> assign(:gallery_images, gallery_images)
|> assign(:related_products, related_products) |> assign(:related_products, related_products)
|> assign(:quantity, 1) |> assign(:quantity, 1)
@ -80,22 +84,61 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
defp variant_price(_, %{cheapest_price: price}), do: price defp variant_price(_, %{cheapest_price: price}), do: price
defp variant_price(_, _), do: 0 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 defp find_variant(variants, selected_options) do
Enum.find(variants, fn v -> v.options == selected_options end) Enum.find(variants, fn v -> v.options == selected_options end)
end end
@impl true defp filter_gallery_images(all_images, selected_color) do
def handle_event("select_option", %{"option" => option_name, "value" => value}, socket) do if selected_color do
selected_options = Map.put(socket.assigns.selected_options, option_name, value) 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 = available_options =
compute_available_options( compute_available_options(option_types, variants, selected_options)
socket.assigns.option_types,
socket.assigns.variants, gallery_images = filter_gallery_images(socket.assigns.all_images, selected_options["Color"])
selected_options
)
socket = socket =
socket socket
@ -103,6 +146,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
|> assign(:selected_variant, selected_variant) |> assign(:selected_variant, selected_variant)
|> assign(:available_options, available_options) |> assign(:available_options, available_options)
|> assign(:display_price, variant_price(selected_variant, socket.assigns.product)) |> assign(:display_price, variant_price(selected_variant, socket.assigns.product))
|> assign(:gallery_images, gallery_images)
{:noreply, socket} {:noreply, socket}
end end

View File

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

View File

@ -216,22 +216,23 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
html = html =
view view
|> element("button[phx-value-value='18x24']") |> element("button[phx-value-selected='18x24']")
|> render_click() |> render_click()
assert html =~ "£32.00" assert html =~ "£32.00"
end 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}") {:ok, view, _html} = live(conn, ~p"/products/#{shirt.slug}")
# Select White — M and L are available, XL is not
html = html =
view view
|> element("button[aria-label='Select White']") |> element("button[aria-label='Select White']")
|> render_click() |> render_click()
# XL should be disabled (unavailable in white) # White is selected, size M should still be selected (valid combo)
assert html =~ "disabled" assert html =~ ~s(aria-pressed="true")
end end
test "shows variant for single-variant product", %{conn: conn, related: related} do test "shows variant for single-variant product", %{conn: conn, related: related} do