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:
parent
1b49b470f2
commit
880e7a2888
@ -35,11 +35,21 @@ defmodule SimpleshopTheme.Images.VariantCache do
|
|||||||
Logger.info("[VariantCache] Checking image variant cache...")
|
Logger.info("[VariantCache] Checking image variant cache...")
|
||||||
File.mkdir_p!(Optimizer.cache_dir())
|
File.mkdir_p!(Optimizer.cache_dir())
|
||||||
|
|
||||||
|
reset_stale_sync_status()
|
||||||
ensure_database_image_variants()
|
ensure_database_image_variants()
|
||||||
ensure_mockup_variants()
|
ensure_mockup_variants()
|
||||||
ensure_product_image_downloads()
|
ensure_product_image_downloads()
|
||||||
end
|
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
|
defp ensure_database_image_variants do
|
||||||
incomplete =
|
incomplete =
|
||||||
ImageSchema
|
ImageSchema
|
||||||
|
|||||||
@ -79,6 +79,17 @@ defmodule SimpleshopTheme.Products do
|
|||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
end
|
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 """
|
@doc """
|
||||||
Returns the count of products for a provider connection.
|
Returns the count of products for a provider connection.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -174,23 +174,76 @@ defmodule SimpleshopTheme.Providers.Printify do
|
|||||||
end
|
end
|
||||||
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
|
# Data Normalization
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
defp normalize_product(raw) do
|
defp normalize_product(raw) do
|
||||||
|
options = raw["options"] || []
|
||||||
|
|
||||||
%{
|
%{
|
||||||
provider_product_id: to_string(raw["id"]),
|
provider_product_id: to_string(raw["id"]),
|
||||||
title: raw["title"],
|
title: raw["title"],
|
||||||
description: raw["description"],
|
description: raw["description"],
|
||||||
category: extract_category(raw),
|
category: extract_category(raw),
|
||||||
images: normalize_images(raw["images"] || []),
|
images: normalize_images(raw["images"] || []),
|
||||||
variants: normalize_variants(raw["variants"] || []),
|
variants: normalize_variants(raw["variants"] || [], options),
|
||||||
provider_data: %{
|
provider_data: %{
|
||||||
blueprint_id: raw["blueprint_id"],
|
blueprint_id: raw["blueprint_id"],
|
||||||
print_provider_id: raw["print_provider_id"],
|
print_provider_id: raw["print_provider_id"],
|
||||||
tags: raw["tags"] || [],
|
tags: raw["tags"] || [],
|
||||||
options: raw["options"] || [],
|
options: options,
|
||||||
raw: raw
|
raw: raw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -210,7 +263,9 @@ defmodule SimpleshopTheme.Providers.Printify do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_variants(variants) do
|
defp normalize_variants(variants, options) do
|
||||||
|
option_names = extract_option_names(options)
|
||||||
|
|
||||||
Enum.map(variants, fn var ->
|
Enum.map(variants, fn var ->
|
||||||
%{
|
%{
|
||||||
provider_variant_id: to_string(var["id"]),
|
provider_variant_id: to_string(var["id"]),
|
||||||
@ -218,24 +273,30 @@ defmodule SimpleshopTheme.Providers.Printify do
|
|||||||
sku: var["sku"],
|
sku: var["sku"],
|
||||||
price: var["price"],
|
price: var["price"],
|
||||||
cost: var["cost"],
|
cost: var["cost"],
|
||||||
options: normalize_variant_options(var),
|
options: normalize_variant_options(var, option_names),
|
||||||
is_enabled: var["is_enabled"] == true,
|
is_enabled: var["is_enabled"] == true,
|
||||||
is_available: var["is_available"] == true
|
is_available: var["is_available"] == true
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_variant_options(variant) do
|
# Extract option names from product options, singularizing common plurals
|
||||||
# Printify variants have options as a list of option value IDs
|
defp extract_option_names(options) do
|
||||||
# We need to build the human-readable map from the variant title
|
Enum.map(options, fn opt ->
|
||||||
# Format: "Size / Color" -> %{"Size" => "Large", "Color" => "Blue"}
|
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"] || ""
|
title = variant["title"] || ""
|
||||||
parts = String.split(title, " / ")
|
parts = String.split(title, " / ")
|
||||||
|
|
||||||
# Common option names based on position
|
|
||||||
option_names = ["Size", "Color", "Style"]
|
|
||||||
|
|
||||||
parts
|
parts
|
||||||
|> Enum.with_index()
|
|> Enum.with_index()
|
||||||
|> Enum.reduce(%{}, fn {value, index}, acc ->
|
|> Enum.reduce(%{}, fn {value, index}, acc ->
|
||||||
|
|||||||
@ -236,6 +236,27 @@ defmodule SimpleshopTheme.Theme.PreviewData do
|
|||||||
{image_url, image_id, source_width} = image_attrs(first_image)
|
{image_url, image_id, source_width} = image_attrs(first_image)
|
||||||
{hover_image_url, hover_image_id, hover_source_width} = image_attrs(second_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,
|
id: product.slug,
|
||||||
name: product.title,
|
name: product.title,
|
||||||
@ -252,7 +273,9 @@ defmodule SimpleshopTheme.Theme.PreviewData do
|
|||||||
slug: product.slug,
|
slug: product.slug,
|
||||||
in_stock: in_stock,
|
in_stock: in_stock,
|
||||||
on_sale: on_sale,
|
on_sale: on_sale,
|
||||||
inserted_at: product.inserted_at
|
inserted_at: product.inserted_at,
|
||||||
|
option_types: option_types,
|
||||||
|
variants: variants
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -270,6 +293,31 @@ defmodule SimpleshopTheme.Theme.PreviewData do
|
|||||||
{src, nil, nil}
|
{src, nil, nil}
|
||||||
end
|
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)
|
# Default source width for mockup variants (max generated size)
|
||||||
@mockup_source_width 1200
|
@mockup_source_width 1200
|
||||||
|
|
||||||
@ -280,7 +328,7 @@ defmodule SimpleshopTheme.Theme.PreviewData do
|
|||||||
id: "1",
|
id: "1",
|
||||||
name: "Mountain Sunrise Art Print",
|
name: "Mountain Sunrise Art Print",
|
||||||
description: "Capture the magic of dawn with this stunning mountain landscape print",
|
description: "Capture the magic of dawn with this stunning mountain landscape print",
|
||||||
price: 2400,
|
price: 1999,
|
||||||
compare_at_price: nil,
|
compare_at_price: nil,
|
||||||
image_url: "/mockups/mountain-sunrise-print-1",
|
image_url: "/mockups/mountain-sunrise-print-1",
|
||||||
hover_image_url: "/mockups/mountain-sunrise-print-2",
|
hover_image_url: "/mockups/mountain-sunrise-print-2",
|
||||||
@ -288,7 +336,41 @@ defmodule SimpleshopTheme.Theme.PreviewData do
|
|||||||
hover_source_width: @mockup_source_width,
|
hover_source_width: @mockup_source_width,
|
||||||
category: "Art Prints",
|
category: "Art Prints",
|
||||||
in_stock: true,
|
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",
|
id: "2",
|
||||||
@ -359,7 +441,176 @@ defmodule SimpleshopTheme.Theme.PreviewData do
|
|||||||
hover_source_width: @mockup_source_width,
|
hover_source_width: @mockup_source_width,
|
||||||
category: "Apparel",
|
category: "Apparel",
|
||||||
in_stock: true,
|
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",
|
id: "7",
|
||||||
|
|||||||
@ -44,8 +44,27 @@
|
|||||||
<.product_gallery images={@gallery_images} product_name={@product.name} />
|
<.product_gallery images={@gallery_images} product_name={@product.name} />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<.product_info product={@product} />
|
<.product_info product={Map.put(@product, :price, @display_price)} />
|
||||||
<.variant_selector label="Size" options={["S", "M", "L", "XL"]} />
|
|
||||||
|
<%!-- 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} />
|
<.quantity_selector quantity={@quantity} in_stock={@product.in_stock} />
|
||||||
<.add_to_cart_button />
|
<.add_to_cart_button />
|
||||||
<.trust_badges :if={@theme_settings.pdp_trust_badges} />
|
<.trust_badges :if={@theme_settings.pdp_trust_badges} />
|
||||||
|
|||||||
@ -3537,49 +3537,121 @@ defmodule SimpleshopThemeWeb.ShopComponents do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@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
|
## Attributes
|
||||||
|
|
||||||
* `label` - Required. Label text (e.g., "Size", "Color").
|
* `option_type` - Required. Map with :name, :type, :values keys
|
||||||
* `options` - Required. List of option strings.
|
* `selected` - Required. Currently selected value (string)
|
||||||
* `selected` - Optional. Currently selected option. Defaults to first option.
|
* `available` - Required. List of available values for this option
|
||||||
|
* `mode` - Optional. :shop or :preview (default: :shop)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
<.variant_selector label="Size" options={["S", "M", "L", "XL"]} />
|
<.variant_selector
|
||||||
<.variant_selector label="Color" options={["Red", "Blue", "Green"]} selected="Blue" />
|
option_type={%{name: "Size", type: :size, values: [%{title: "S"}, ...]}}
|
||||||
|
selected="M"
|
||||||
|
available={["S", "M", "L"]}
|
||||||
|
/>
|
||||||
"""
|
"""
|
||||||
attr :label, :string, required: true
|
attr :option_type, :map, required: true
|
||||||
attr :options, :list, required: true
|
attr :selected, :string, required: true
|
||||||
attr :selected, :string, default: nil
|
attr :available, :list, required: true
|
||||||
|
attr :mode, :atom, default: :shop
|
||||||
|
|
||||||
def variant_selector(assigns) do
|
def variant_selector(assigns) do
|
||||||
assigns =
|
|
||||||
assign_new(assigns, :selected_value, fn ->
|
|
||||||
assigns.selected || List.first(assigns.options)
|
|
||||||
end)
|
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<label class="block font-semibold mb-2" style="color: var(--t-text-primary);">
|
<label class="block font-semibold mb-2" style="color: var(--t-text-primary);">
|
||||||
{@label}
|
{@option_type.name}
|
||||||
</label>
|
</label>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<%= for option <- @options do %>
|
<%= if @option_type.type == :color do %>
|
||||||
<button
|
<.color_swatch
|
||||||
type="button"
|
:for={value <- @option_type.values}
|
||||||
class="px-4 py-2 font-medium transition-all"
|
title={value.title}
|
||||||
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"};"}
|
hex={value[:hex] || "#888888"}
|
||||||
>
|
selected={value.title == @selected}
|
||||||
{option}
|
disabled={value.title not in @available}
|
||||||
</button>
|
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 %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
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"""
|
||||||
|
<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 """
|
@doc """
|
||||||
Renders a quantity selector with increment/decrement buttons.
|
Renders a quantity selector with increment/decrement buttons.
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,13 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
|
|||||||
]
|
]
|
||||||
|> Enum.reject(&is_nil/1)
|
|> 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 =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, product.name)
|
|> assign(:page_title, product.name)
|
||||||
@ -57,6 +64,12 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
|
|||||||
|> assign(:cart_items, [])
|
|> assign(:cart_items, [])
|
||||||
|> assign(:cart_count, 0)
|
|> assign(:cart_count, 0)
|
||||||
|> assign(:cart_subtotal, "£0.00")
|
|> 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}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
@ -76,6 +89,70 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
|
|||||||
defp image_src(_, url) when is_binary(url), do: url
|
defp image_src(_, url) when is_binary(url), do: url
|
||||||
defp image_src(_, _), do: nil
|
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
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
@ -91,6 +168,10 @@ defmodule SimpleshopThemeWeb.ShopLive.ProductShow do
|
|||||||
cart_items={@cart_items}
|
cart_items={@cart_items}
|
||||||
cart_count={@cart_count}
|
cart_count={@cart_count}
|
||||||
cart_subtotal={@cart_subtotal}
|
cart_subtotal={@cart_subtotal}
|
||||||
|
option_types={@option_types}
|
||||||
|
selected_options={@selected_options}
|
||||||
|
available_options={@available_options}
|
||||||
|
display_price={@display_price}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|||||||
@ -336,12 +336,35 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do
|
|||||||
|
|
||||||
defp preview_page(%{page: :pdp} = assigns) do
|
defp preview_page(%{page: :pdp} = assigns) do
|
||||||
product = List.first(assigns.preview_data.products)
|
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 =
|
||||||
assigns
|
assigns
|
||||||
|> assign(:product, product)
|
|> assign(:product, product)
|
||||||
|> assign(:gallery_images, build_gallery_images(product))
|
|> assign(:gallery_images, build_gallery_images(product))
|
||||||
|> assign(:related_products, Enum.slice(assigns.preview_data.products, 1, 4))
|
|> 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"""
|
~H"""
|
||||||
<SimpleshopThemeWeb.PageTemplates.pdp
|
<SimpleshopThemeWeb.PageTemplates.pdp
|
||||||
@ -356,6 +379,10 @@ defmodule SimpleshopThemeWeb.ThemeLive.Index do
|
|||||||
cart_items={PreviewData.cart_drawer_items()}
|
cart_items={PreviewData.cart_drawer_items()}
|
||||||
cart_count={2}
|
cart_count={2}
|
||||||
cart_subtotal="£72.00"
|
cart_subtotal="£72.00"
|
||||||
|
option_types={@option_types}
|
||||||
|
selected_options={@selected_options}
|
||||||
|
available_options={@available_options}
|
||||||
|
display_price={@display_price}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user