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:
@@ -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} />
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
<SimpleshopThemeWeb.PageTemplates.pdp
|
||||
@@ -356,6 +379,10 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do
|
||||
cart_items={PreviewData.cart_drawer_items()}
|
||||
cart_count={2}
|
||||
cart_subtotal="£72.00"
|
||||
option_types={@option_types}
|
||||
selected_options={@selected_options}
|
||||
available_options={@available_options}
|
||||
display_price={@display_price}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user