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} />