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

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

View File

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