diff --git a/lib/simpleshop_theme/clients/printify.ex b/lib/simpleshop_theme/clients/printify.ex
index 7a8c029..aea6478 100644
--- a/lib/simpleshop_theme/clients/printify.ex
+++ b/lib/simpleshop_theme/clients/printify.ex
@@ -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.
"""
diff --git a/lib/simpleshop_theme/mockups/generator.ex b/lib/simpleshop_theme/mockups/generator.ex
index df85f0f..2c9b2e0 100644
--- a/lib/simpleshop_theme/mockups/generator.ex
+++ b/lib/simpleshop_theme/mockups/generator.ex
@@ -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.
"""
diff --git a/lib/simpleshop_theme/products.ex b/lib/simpleshop_theme/products.ex
index 37d7beb..fefede1 100644
--- a/lib/simpleshop_theme/products.ex
+++ b/lib/simpleshop_theme/products.ex
@@ -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 ->
- {: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)
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
diff --git a/lib/simpleshop_theme/products/product.ex b/lib/simpleshop_theme/products/product.ex
index 6b1242e..36f2a19 100644
--- a/lib/simpleshop_theme/products/product.ex
+++ b/lib/simpleshop_theme/products/product.ex
@@ -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.
"""
diff --git a/lib/simpleshop_theme/products/product_image.ex b/lib/simpleshop_theme/products/product_image.ex
index 3e4bc79..f29dd84 100644
--- a/lib/simpleshop_theme/products/product_image.ex
+++ b/lib/simpleshop_theme/products/product_image.ex
@@ -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)
diff --git a/lib/simpleshop_theme/providers/printful.ex b/lib/simpleshop_theme/providers/printful.ex
index 2060193..338675f 100644
--- a/lib/simpleshop_theme/providers/printful.ex
+++ b/lib/simpleshop_theme/providers/printful.ex
@@ -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)
diff --git a/lib/simpleshop_theme/providers/printify.ex b/lib/simpleshop_theme/providers/printify.ex
index cd9df9a..0696e45 100644
--- a/lib/simpleshop_theme/providers/printify.ex
+++ b/lib/simpleshop_theme/providers/printify.ex
@@ -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)
diff --git a/lib/simpleshop_theme/sync/mockup_enricher.ex b/lib/simpleshop_theme/sync/mockup_enricher.ex
index 3b6dc38..7fc6e1f 100644
--- a/lib/simpleshop_theme/sync/mockup_enricher.ex
+++ b/lib/simpleshop_theme/sync/mockup_enricher.ex
@@ -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,33 +99,96 @@ defmodule SimpleshopTheme.Sync.MockupEnricher do
:ok
true ->
- # Pick one representative variant for mockup generation
- 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
+ enrich_all_colours(product, catalog_product_id, artwork_url, color_variant_map)
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} =
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
+ 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_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
- 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
+ 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
diff --git a/lib/simpleshop_theme/sync/product_sync_worker.ex b/lib/simpleshop_theme/sync/product_sync_worker.ex
index 8fa0662..8f65e2a 100644
--- a/lib/simpleshop_theme/sync/product_sync_worker.ex
+++ b/lib/simpleshop_theme/sync/product_sync_worker.ex
@@ -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)
diff --git a/lib/simpleshop_theme_web/components/shop_components/product.ex b/lib/simpleshop_theme_web/components/shop_components/product.ex
index eb0c8f4..6e0310b 100644
--- a/lib/simpleshop_theme_web/components/shop_components/product.ex
+++ b/lib/simpleshop_theme_web/components/shop_components/product.ex
@@ -1530,23 +1530,18 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
~H"""
"""
end
@@ -1561,16 +1556,14 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
~H"""
diff --git a/lib/simpleshop_theme_web/live/shop/product_show.ex b/lib/simpleshop_theme_web/live/shop/product_show.ex
index 5b14cbb..e7e8d63 100644
--- a/lib/simpleshop_theme_web/live/shop/product_show.ex
+++ b/lib/simpleshop_theme_web/live/shop/product_show.ex
@@ -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
diff --git a/priv/repo/migrations/20260215205353_add_color_to_product_images.exs b/priv/repo/migrations/20260215205353_add_color_to_product_images.exs
new file mode 100644
index 0000000..96439b0
--- /dev/null
+++ b/priv/repo/migrations/20260215205353_add_color_to_product_images.exs
@@ -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
diff --git a/test/simpleshop_theme_web/live/shop/product_show_test.exs b/test/simpleshop_theme_web/live/shop/product_show_test.exs
index 8c976ec..e86a6a5 100644
--- a/test/simpleshop_theme_web/live/shop/product_show_test.exs
+++ b/test/simpleshop_theme_web/live/shop/product_show_test.exs
@@ -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