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 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-03 22:17:48 +00:00
parent 1b49b470f2
commit 880e7a2888
8 changed files with 572 additions and 40 deletions

View File

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

View File

@@ -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.
"""

View File

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

View File

@@ -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",