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

@@ -44,8 +44,27 @@
<.product_gallery images={@gallery_images} product_name={@product.name} />
<div>
<.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 --%>
<div
:if={@option_types == []}
class="mb-6 text-sm"
style="color: var(--t-text-secondary);"
>
One size
</div>
<.quantity_selector quantity={@quantity} in_stock={@product.in_stock} />
<.add_to_cart_button />
<.trust_badges :if={@theme_settings.pdp_trust_badges} />

View File

@@ -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"""
<div class="mb-6">
<label class="block font-semibold mb-2" style="color: var(--t-text-primary);">
{@label}
{@option_type.name}
</label>
<div class="flex flex-wrap gap-2">
<%= for option <- @options do %>
<button
type="button"
class="px-4 py-2 font-medium transition-all"
style={"border: 2px solid #{if option == @selected_value, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l))", else: "var(--t-border-default)"}; border-radius: var(--t-radius-button); color: var(--t-text-primary); background: #{if option == @selected_value, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.1)", else: "transparent"};"}
>
{option}
</button>
<%= 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 %>
</div>
</div>
"""
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"""
<button
type="button"
phx-click={if @mode == :shop and not @disabled, do: "select_option"}
phx-value-option={@option_name}
phx-value-value={@title}
class={[
"w-10 h-10 rounded-full border-2 transition-all relative",
@selected && "ring-2 ring-offset-2",
@disabled && "opacity-40 cursor-not-allowed"
]}
style={"background-color: #{@hex}; border-color: #{if @selected, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l))", else: "var(--t-border-default)"}; --tw-ring-color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));"}
title={@title}
disabled={@disabled}
aria-label={"Select #{@title}"}
aria-pressed={@selected}
>
<span :if={@disabled} class="absolute inset-0 flex items-center justify-center">
<span class="w-full h-0.5 bg-gray-400 rotate-45 absolute"></span>
</span>
</button>
"""
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"""
<button
type="button"
phx-click={if @mode == :shop and not @disabled, do: "select_option"}
phx-value-option={@option_name}
phx-value-value={@title}
class={[
"px-4 py-2 font-medium transition-all",
@disabled && "opacity-40 cursor-not-allowed line-through"
]}
style={"border: 2px solid #{if @selected, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l))", else: "var(--t-border-default)"}; border-radius: var(--t-radius-button); color: var(--t-text-primary); background: #{if @selected, do: "hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l) / 0.1)", else: "transparent"};"}
disabled={@disabled}
aria-pressed={@selected}
>
{@title}
</button>
"""
end
@doc """
Renders a quantity selector with increment/decrement buttons.