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