From 880e7a2888b16b75d9de2420b40d9b441955faa6 Mon Sep 17 00:00:00 2001 From: jamey Date: Tue, 3 Feb 2026 22:17:48 +0000 Subject: [PATCH] feat: add dynamic variant selector with color swatches - Fix Printify options parsing (Color/Size were swapped) - Add extract_option_types/1 for frontend display with hex colors - Filter option types to only published variants (not full catalog) - Track selected variant in LiveView with price updates - Color swatches for color-type options, text buttons for size - Disable unavailable combinations - Add startup recovery for stale sync status Co-Authored-By: Claude Opus 4.5 --- lib/simpleshop_theme/images/variant_cache.ex | 10 + lib/simpleshop_theme/products.ex | 11 + lib/simpleshop_theme/providers/printify.ex | 83 +++++- lib/simpleshop_theme/theme/preview_data.ex | 259 +++++++++++++++++- .../components/page_templates/pdp.html.heex | 23 +- .../components/shop_components.ex | 118 ++++++-- .../live/shop_live/product_show.ex | 81 ++++++ .../live/theme_live/index.ex | 27 ++ 8 files changed, 572 insertions(+), 40 deletions(-) diff --git a/lib/simpleshop_theme/images/variant_cache.ex b/lib/simpleshop_theme/images/variant_cache.ex index 1a6727d..54d416c 100644 --- a/lib/simpleshop_theme/images/variant_cache.ex +++ b/lib/simpleshop_theme/images/variant_cache.ex @@ -35,11 +35,21 @@ defmodule SimpleshopTheme.Images.VariantCache do Logger.info("[VariantCache] Checking image variant cache...") File.mkdir_p!(Optimizer.cache_dir()) + reset_stale_sync_status() ensure_database_image_variants() ensure_mockup_variants() ensure_product_image_downloads() end + # Reset any provider connections stuck in "syncing" status from interrupted syncs + defp reset_stale_sync_status do + {count, _} = Products.reset_stale_sync_status() + + if count > 0 do + Logger.info("[VariantCache] Reset #{count} stale sync status(es) to idle") + end + end + defp ensure_database_image_variants do incomplete = ImageSchema diff --git a/lib/simpleshop_theme/products.ex b/lib/simpleshop_theme/products.ex index 17e1955..7355095 100644 --- a/lib/simpleshop_theme/products.ex +++ b/lib/simpleshop_theme/products.ex @@ -79,6 +79,17 @@ defmodule SimpleshopTheme.Products do |> Repo.update() end + @doc """ + Resets any stale "syncing" status to "idle". + + Called on application startup to recover from interrupted syncs + (e.g., node shutdown while sync was running). + """ + def reset_stale_sync_status do + from(c in ProviderConnection, where: c.sync_status == "syncing") + |> Repo.update_all(set: [sync_status: "idle"]) + end + @doc """ Returns the count of products for a provider connection. """ diff --git a/lib/simpleshop_theme/providers/printify.ex b/lib/simpleshop_theme/providers/printify.ex index 843ab9f..35fcf64 100644 --- a/lib/simpleshop_theme/providers/printify.ex +++ b/lib/simpleshop_theme/providers/printify.ex @@ -174,23 +174,76 @@ defmodule SimpleshopTheme.Providers.Printify do end end + # ============================================================================= + # Option Types Extraction (for frontend) + # ============================================================================= + + @doc """ + Extracts option types from Printify provider_data for frontend display. + + Returns a list of option type maps with normalized names, types, and values + including hex color codes for color-type options. + + ## Examples + + iex> extract_option_types(%{"options" => [ + ...> %{"name" => "Colors", "type" => "color", "values" => [ + ...> %{"id" => 1, "title" => "Black", "colors" => ["#000000"]} + ...> ]} + ...> ]}) + [%{name: "Color", type: :color, values: [%{id: 1, title: "Black", hex: "#000000"}]}] + """ + def extract_option_types(%{"options" => options}) when is_list(options) do + Enum.map(options, fn opt -> + %{ + name: singularize_option_name(opt["name"]), + type: option_type_atom(opt["type"]), + values: extract_option_values(opt) + } + end) + end + + def extract_option_types(_), do: [] + + defp option_type_atom("color"), do: :color + defp option_type_atom("size"), do: :size + defp option_type_atom(_), do: :other + + defp extract_option_values(%{"values" => values, "type" => "color"}) do + Enum.map(values, fn val -> + %{ + id: val["id"], + title: val["title"], + hex: List.first(val["colors"]) + } + end) + end + + defp extract_option_values(%{"values" => values}) do + Enum.map(values, fn val -> + %{id: val["id"], title: val["title"]} + end) + end + # ============================================================================= # Data Normalization # ============================================================================= defp normalize_product(raw) do + options = raw["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"] || []), + variants: normalize_variants(raw["variants"] || [], options), provider_data: %{ blueprint_id: raw["blueprint_id"], print_provider_id: raw["print_provider_id"], tags: raw["tags"] || [], - options: raw["options"] || [], + options: options, raw: raw } } @@ -210,7 +263,9 @@ defmodule SimpleshopTheme.Providers.Printify do end) end - defp normalize_variants(variants) do + defp normalize_variants(variants, options) do + option_names = extract_option_names(options) + Enum.map(variants, fn var -> %{ provider_variant_id: to_string(var["id"]), @@ -218,24 +273,30 @@ defmodule SimpleshopTheme.Providers.Printify do sku: var["sku"], price: var["price"], cost: var["cost"], - options: normalize_variant_options(var), + options: normalize_variant_options(var, option_names), is_enabled: var["is_enabled"] == true, is_available: var["is_available"] == true } end) end - defp normalize_variant_options(variant) do - # Printify variants have options as a list of option value IDs - # We need to build the human-readable map from the variant title - # Format: "Size / Color" -> %{"Size" => "Large", "Color" => "Blue"} + # Extract option names from product options, singularizing common plurals + defp extract_option_names(options) do + Enum.map(options, fn opt -> + singularize_option_name(opt["name"]) + end) + end + defp singularize_option_name("Colors"), do: "Color" + defp singularize_option_name("Sizes"), do: "Size" + defp singularize_option_name(name), do: name + + defp normalize_variant_options(variant, option_names) do + # Build human-readable map from variant title + # Title format matches product options order: "Navy / S" for [Colors, Sizes] title = variant["title"] || "" parts = String.split(title, " / ") - # Common option names based on position - option_names = ["Size", "Color", "Style"] - parts |> Enum.with_index() |> Enum.reduce(%{}, fn {value, index}, acc -> diff --git a/lib/simpleshop_theme/theme/preview_data.ex b/lib/simpleshop_theme/theme/preview_data.ex index 674149d..3cea92a 100644 --- a/lib/simpleshop_theme/theme/preview_data.ex +++ b/lib/simpleshop_theme/theme/preview_data.ex @@ -236,6 +236,27 @@ defmodule SimpleshopTheme.Theme.PreviewData do {image_url, image_id, source_width} = image_attrs(first_image) {hover_image_url, hover_image_id, hover_source_width} = image_attrs(second_image) + # Map variants to frontend format (only enabled/published ones) + variants = + product.variants + |> Enum.filter(& &1.is_enabled) + |> Enum.map(fn v -> + %{ + id: v.id, + provider_variant_id: v.provider_variant_id, + title: v.title, + price: v.price, + compare_at_price: v.compare_at_price, + options: v.options, + is_available: v.is_available + } + end) + + # Extract option types, filtered to only values present in enabled variants + option_types = + SimpleshopTheme.Providers.Printify.extract_option_types(product.provider_data) + |> filter_option_types_by_variants(variants) + %{ id: product.slug, name: product.title, @@ -252,7 +273,9 @@ defmodule SimpleshopTheme.Theme.PreviewData do slug: product.slug, in_stock: in_stock, on_sale: on_sale, - inserted_at: product.inserted_at + inserted_at: product.inserted_at, + option_types: option_types, + variants: variants } end @@ -270,6 +293,31 @@ defmodule SimpleshopTheme.Theme.PreviewData do {src, nil, nil} end + # Filter option types to only include values present in enabled variants + # This ensures we don't show unpublished options from the Printify catalog + defp filter_option_types_by_variants(option_types, variants) do + # Collect all option values present in the enabled variants + values_in_use = + Enum.reduce(variants, %{}, fn variant, acc -> + Enum.reduce(variant.options, acc, fn {opt_name, opt_value}, inner_acc -> + Map.update(inner_acc, opt_name, MapSet.new([opt_value]), &MapSet.put(&1, opt_value)) + end) + end) + + # Filter each option type's values to only those in use + option_types + |> Enum.map(fn opt_type -> + used_values = Map.get(values_in_use, opt_type.name, MapSet.new()) + + filtered_values = + opt_type.values + |> Enum.filter(fn v -> MapSet.member?(used_values, v.title) end) + + %{opt_type | values: filtered_values} + end) + |> Enum.reject(fn opt_type -> opt_type.values == [] end) + end + # Default source width for mockup variants (max generated size) @mockup_source_width 1200 @@ -280,7 +328,7 @@ defmodule SimpleshopTheme.Theme.PreviewData do id: "1", name: "Mountain Sunrise Art Print", description: "Capture the magic of dawn with this stunning mountain landscape print", - price: 2400, + price: 1999, compare_at_price: nil, image_url: "/mockups/mountain-sunrise-print-1", hover_image_url: "/mockups/mountain-sunrise-print-2", @@ -288,7 +336,41 @@ defmodule SimpleshopTheme.Theme.PreviewData do hover_source_width: @mockup_source_width, category: "Art Prints", in_stock: true, - on_sale: false + on_sale: false, + option_types: [ + %{ + name: "Size", + type: :size, + values: [ + %{id: 1, title: "8×10"}, + %{id: 2, title: "12×18"}, + %{id: 3, title: "18×24"} + ] + } + ], + variants: [ + %{ + id: "p1", + title: "8×10", + price: 1999, + options: %{"Size" => "8×10"}, + is_available: true + }, + %{ + id: "p2", + title: "12×18", + price: 2400, + options: %{"Size" => "12×18"}, + is_available: true + }, + %{ + id: "p3", + title: "18×24", + price: 3200, + options: %{"Size" => "18×24"}, + is_available: true + } + ] }, %{ id: "2", @@ -359,7 +441,176 @@ defmodule SimpleshopTheme.Theme.PreviewData do hover_source_width: @mockup_source_width, category: "Apparel", in_stock: true, - on_sale: false + on_sale: false, + option_types: [ + %{ + name: "Color", + type: :color, + values: [ + %{id: 1, title: "Black", hex: "#000000"}, + %{id: 2, title: "Navy", hex: "#1A2237"}, + %{id: 3, title: "White", hex: "#FFFFFF"}, + %{id: 4, title: "Sport Grey", hex: "#9CA3AF"} + ] + }, + %{ + name: "Size", + type: :size, + values: [ + %{id: 10, title: "S"}, + %{id: 11, title: "M"}, + %{id: 12, title: "L"}, + %{id: 13, title: "XL"}, + %{id: 14, title: "2XL"} + ] + } + ], + variants: [ + # Black variants + %{ + id: "t1", + title: "Black / S", + price: 2999, + options: %{"Color" => "Black", "Size" => "S"}, + is_available: true + }, + %{ + id: "t2", + title: "Black / M", + price: 2999, + options: %{"Color" => "Black", "Size" => "M"}, + is_available: true + }, + %{ + id: "t3", + title: "Black / L", + price: 2999, + options: %{"Color" => "Black", "Size" => "L"}, + is_available: true + }, + %{ + id: "t4", + title: "Black / XL", + price: 2999, + options: %{"Color" => "Black", "Size" => "XL"}, + is_available: true + }, + %{ + id: "t5", + title: "Black / 2XL", + price: 3299, + options: %{"Color" => "Black", "Size" => "2XL"}, + is_available: true + }, + # Navy variants + %{ + id: "t6", + title: "Navy / S", + price: 2999, + options: %{"Color" => "Navy", "Size" => "S"}, + is_available: true + }, + %{ + id: "t7", + title: "Navy / M", + price: 2999, + options: %{"Color" => "Navy", "Size" => "M"}, + is_available: true + }, + %{ + id: "t8", + title: "Navy / L", + price: 2999, + options: %{"Color" => "Navy", "Size" => "L"}, + is_available: true + }, + %{ + id: "t9", + title: "Navy / XL", + price: 2999, + options: %{"Color" => "Navy", "Size" => "XL"}, + is_available: true + }, + %{ + id: "t10", + title: "Navy / 2XL", + price: 3299, + options: %{"Color" => "Navy", "Size" => "2XL"}, + is_available: false + }, + # White variants + %{ + id: "t11", + title: "White / S", + price: 2999, + options: %{"Color" => "White", "Size" => "S"}, + is_available: true + }, + %{ + id: "t12", + title: "White / M", + price: 2999, + options: %{"Color" => "White", "Size" => "M"}, + is_available: true + }, + %{ + id: "t13", + title: "White / L", + price: 2999, + options: %{"Color" => "White", "Size" => "L"}, + is_available: true + }, + %{ + id: "t14", + title: "White / XL", + price: 2999, + options: %{"Color" => "White", "Size" => "XL"}, + is_available: false + }, + %{ + id: "t15", + title: "White / 2XL", + price: 3299, + options: %{"Color" => "White", "Size" => "2XL"}, + is_available: false + }, + # Sport Grey variants + %{ + id: "t16", + title: "Sport Grey / S", + price: 2999, + options: %{"Color" => "Sport Grey", "Size" => "S"}, + is_available: true + }, + %{ + id: "t17", + title: "Sport Grey / M", + price: 2999, + options: %{"Color" => "Sport Grey", "Size" => "M"}, + is_available: true + }, + %{ + id: "t18", + title: "Sport Grey / L", + price: 2999, + options: %{"Color" => "Sport Grey", "Size" => "L"}, + is_available: true + }, + %{ + id: "t19", + title: "Sport Grey / XL", + price: 2999, + options: %{"Color" => "Sport Grey", "Size" => "XL"}, + is_available: true + }, + %{ + id: "t20", + title: "Sport Grey / 2XL", + price: 3299, + options: %{"Color" => "Sport Grey", "Size" => "2XL"}, + is_available: true + } + ] }, %{ id: "7", diff --git a/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex b/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex index a13d006..edb5c5c 100644 --- a/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex @@ -44,8 +44,27 @@ <.product_gallery images={@gallery_images} product_name={@product.name} />
- <.product_info product={@product} /> - <.variant_selector label="Size" options={["S", "M", "L", "XL"]} /> + <.product_info product={Map.put(@product, :price, @display_price)} /> + + <%!-- Dynamic variant selectors --%> + <%= for option_type <- @option_types do %> + <.variant_selector + option_type={option_type} + selected={@selected_options[option_type.name]} + available={@available_options[option_type.name] || []} + mode={@mode} + /> + <% end %> + + <%!-- Fallback for products with no variant options --%> +
+ One size +
+ <.quantity_selector quantity={@quantity} in_stock={@product.in_stock} /> <.add_to_cart_button /> <.trust_badges :if={@theme_settings.pdp_trust_badges} /> diff --git a/lib/simpleshop_theme_web/components/shop_components.ex b/lib/simpleshop_theme_web/components/shop_components.ex index fc28661..532bfcc 100644 --- a/lib/simpleshop_theme_web/components/shop_components.ex +++ b/lib/simpleshop_theme_web/components/shop_components.ex @@ -3537,49 +3537,121 @@ defmodule SimpleshopThemeWeb.ShopComponents do end @doc """ - Renders a variant selector with button options. + Renders a variant selector for a single option type. + + Shows color swatches for color-type options, text buttons for others. + Disables unavailable options and fires `select_option` event on click. ## Attributes - * `label` - Required. Label text (e.g., "Size", "Color"). - * `options` - Required. List of option strings. - * `selected` - Optional. Currently selected option. Defaults to first option. + * `option_type` - Required. Map with :name, :type, :values keys + * `selected` - Required. Currently selected value (string) + * `available` - Required. List of available values for this option + * `mode` - Optional. :shop or :preview (default: :shop) ## Examples - <.variant_selector label="Size" options={["S", "M", "L", "XL"]} /> - <.variant_selector label="Color" options={["Red", "Blue", "Green"]} selected="Blue" /> + <.variant_selector + option_type={%{name: "Size", type: :size, values: [%{title: "S"}, ...]}} + selected="M" + available={["S", "M", "L"]} + /> """ - attr :label, :string, required: true - attr :options, :list, required: true - attr :selected, :string, default: nil + attr :option_type, :map, required: true + attr :selected, :string, required: true + attr :available, :list, required: true + attr :mode, :atom, default: :shop def variant_selector(assigns) do - assigns = - assign_new(assigns, :selected_value, fn -> - assigns.selected || List.first(assigns.options) - end) - ~H"""
- <%= for option <- @options do %> - + <%= if @option_type.type == :color do %> + <.color_swatch + :for={value <- @option_type.values} + title={value.title} + hex={value[:hex] || "#888888"} + selected={value.title == @selected} + disabled={value.title not in @available} + option_name={@option_type.name} + mode={@mode} + /> + <% else %> + <.size_button + :for={value <- @option_type.values} + title={value.title} + selected={value.title == @selected} + disabled={value.title not in @available} + option_name={@option_type.name} + mode={@mode} + /> <% end %>
""" end + attr :title, :string, required: true + attr :hex, :string, required: true + attr :selected, :boolean, required: true + attr :disabled, :boolean, required: true + attr :option_name, :string, required: true + attr :mode, :atom, default: :shop + + defp color_swatch(assigns) do + ~H""" + + """ + end + + attr :title, :string, required: true + attr :selected, :boolean, required: true + attr :disabled, :boolean, required: true + attr :option_name, :string, required: true + attr :mode, :atom, default: :shop + + defp size_button(assigns) do + ~H""" + + """ + end + @doc """ Renders a quantity selector with increment/decrement buttons. diff --git a/lib/simpleshop_theme_web/live/shop_live/product_show.ex b/lib/simpleshop_theme_web/live/shop_live/product_show.ex index 25e746b..8ebea8f 100644 --- a/lib/simpleshop_theme_web/live/shop_live/product_show.ex +++ b/lib/simpleshop_theme_web/live/shop_live/product_show.ex @@ -42,6 +42,13 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do ] |> Enum.reject(&is_nil/1) + # Initialize variant selection + option_types = product[:option_types] || [] + 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) + socket = socket |> assign(:page_title, product.name) @@ -57,6 +64,12 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do |> assign(:cart_items, []) |> assign(:cart_count, 0) |> assign(:cart_subtotal, "£0.00") + |> assign(:option_types, option_types) + |> assign(:variants, variants) + |> assign(:selected_options, selected_options) + |> assign(:selected_variant, selected_variant) + |> assign(:available_options, available_options) + |> assign(:display_price, display_price) {:ok, socket} end @@ -76,6 +89,70 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do defp image_src(_, url) when is_binary(url), do: url defp image_src(_, _), do: nil + # Select first available variant by default + defp initialize_variant_selection([first | _] = _variants) do + {first.options, first} + end + + defp initialize_variant_selection([]) do + {%{}, nil} + end + + # Compute which option values are available given current selection + defp compute_available_options(option_types, variants, selected_options) do + Enum.reduce(option_types, %{}, fn opt_type, acc -> + # For each option type, find which values have at least one available variant + # when combined with the other selected options + other_options = Map.delete(selected_options, opt_type.name) + + available_values = + variants + |> Enum.filter(fn v -> + v.is_available && + Enum.all?(other_options, fn {k, selected_val} -> + v.options[k] == selected_val + end) + end) + |> Enum.map(fn v -> v.options[opt_type.name] end) + |> Enum.uniq() + + Map.put(acc, opt_type.name, available_values) + end) + end + + defp variant_price(%{price: price}, _product) when is_integer(price), do: price + defp variant_price(_, %{price: price}), do: price + defp variant_price(_, _), do: 0 + + 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) + + # Find matching variant + selected_variant = find_variant(socket.assigns.variants, selected_options) + + # Recompute available options based on new selection + available_options = + compute_available_options( + socket.assigns.option_types, + socket.assigns.variants, + selected_options + ) + + socket = + socket + |> assign(:selected_options, selected_options) + |> assign(:selected_variant, selected_variant) + |> assign(:available_options, available_options) + |> assign(:display_price, variant_price(selected_variant, socket.assigns.product)) + + {:noreply, socket} + end + @impl true def render(assigns) do ~H""" @@ -91,6 +168,10 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do cart_items={@cart_items} cart_count={@cart_count} cart_subtotal={@cart_subtotal} + option_types={@option_types} + selected_options={@selected_options} + available_options={@available_options} + display_price={@display_price} /> """ end diff --git a/lib/simpleshop_theme_web/live/theme_live/index.ex b/lib/simpleshop_theme_web/live/theme_live/index.ex index 6f25b5b..ada6c5f 100644 --- a/lib/simpleshop_theme_web/live/theme_live/index.ex +++ b/lib/simpleshop_theme_web/live/theme_live/index.ex @@ -336,12 +336,35 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do defp preview_page(%{page: :pdp} = assigns) do product = List.first(assigns.preview_data.products) + option_types = product[:option_types] || [] + variants = product[:variants] || [] + + # Select first variant by default for preview + {selected_options, selected_variant} = + case variants do + [first | _] -> {first.options, first} + [] -> {%{}, nil} + end + + # All options available in preview mode (show all values) + available_options = + Enum.reduce(option_types, %{}, fn opt, acc -> + values = Enum.map(opt.values, & &1.title) + Map.put(acc, opt.name, values) + end) + + display_price = + if selected_variant, do: selected_variant.price, else: product.price assigns = assigns |> assign(:product, product) |> assign(:gallery_images, build_gallery_images(product)) |> assign(:related_products, Enum.slice(assigns.preview_data.products, 1, 4)) + |> assign(:option_types, option_types) + |> assign(:selected_options, selected_options) + |> assign(:available_options, available_options) + |> assign(:display_price, display_price) ~H""" """ end