wire shop LiveViews to DB queries and improve search UX
Replace PreviewData indirection in all shop LiveViews with direct Products context queries. Home, collection, product detail and error pages now query the database. Categories loaded once in ThemeHook. Cart hydration no longer falls back to mock data. PreviewData kept only for the theme editor. Search modal gains keyboard navigation (arrow keys, Enter, Escape), Cmd+K/Ctrl+K shortcut, full ARIA combobox pattern, LiveView navigate links, and 150ms debounce. SearchModal JS hook manages selection state and highlight. search.ex gets transaction safety on reindex and a public remove_product/1. 10 new integration tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,6 @@ defmodule SimpleshopTheme.Cart do
|
||||
"""
|
||||
|
||||
alias SimpleshopTheme.Products
|
||||
alias SimpleshopTheme.Products.{Product, ProductImage}
|
||||
|
||||
@session_key "cart"
|
||||
|
||||
@@ -118,18 +117,11 @@ defmodule SimpleshopTheme.Cart do
|
||||
else
|
||||
variants_map = Products.get_variants_with_products(variant_ids)
|
||||
|
||||
# Fall back to mock data for variant IDs not found in DB (demo mode)
|
||||
missing_ids = variant_ids -- Map.keys(variants_map)
|
||||
mock_map = if missing_ids != [], do: mock_variants_map(missing_ids), else: %{}
|
||||
|
||||
cart_items
|
||||
|> Enum.map(fn {variant_id, quantity} ->
|
||||
case Map.get(variants_map, variant_id) do
|
||||
nil ->
|
||||
case Map.get(mock_map, variant_id) do
|
||||
nil -> nil
|
||||
item -> %{item | quantity: quantity}
|
||||
end
|
||||
nil
|
||||
|
||||
variant ->
|
||||
%{
|
||||
@@ -170,33 +162,6 @@ defmodule SimpleshopTheme.Cart do
|
||||
end
|
||||
end
|
||||
|
||||
# Build a lookup map from mock product data for variant IDs not in the DB.
|
||||
# Allows the cart to work in demo mode when no real products are synced.
|
||||
defp mock_variants_map(variant_ids) do
|
||||
ids_set = MapSet.new(variant_ids)
|
||||
|
||||
SimpleshopTheme.Theme.PreviewData.products()
|
||||
|> Enum.flat_map(fn product ->
|
||||
(product[:variants] || [])
|
||||
|> Enum.filter(fn v -> MapSet.member?(ids_set, v.id) end)
|
||||
|> Enum.map(fn v ->
|
||||
image = ProductImage.direct_url(Product.primary_image(product), 400)
|
||||
|
||||
{v.id,
|
||||
%{
|
||||
variant_id: v.id,
|
||||
product_id: product[:id],
|
||||
name: product.title,
|
||||
variant: format_variant_options(v.options),
|
||||
price: v.price,
|
||||
quantity: 1,
|
||||
image: image
|
||||
}}
|
||||
end)
|
||||
end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
@@ -102,20 +102,33 @@ defmodule SimpleshopTheme.Products.Product do
|
||||
|
||||
@doc """
|
||||
Extracts option types from provider_data.
|
||||
Returns a list of %{name: "Size", values: ["S", "M", "L"]}.
|
||||
Returns a list of %{name: "Size", type: :size, values: [%{title: "S"}, ...]}.
|
||||
Color options include :hex from the provider's color data.
|
||||
"""
|
||||
def option_types(%{provider_data: %{"options" => options}}) when is_list(options) do
|
||||
Enum.map(options, fn opt ->
|
||||
%{
|
||||
name: opt["name"],
|
||||
values: Enum.map(opt["values"] || [], & &1["title"])
|
||||
}
|
||||
type = option_type_atom(opt["type"])
|
||||
|
||||
values =
|
||||
Enum.map(opt["values"] || [], fn val ->
|
||||
base = %{title: val["title"]}
|
||||
|
||||
case val["colors"] do
|
||||
[hex | _] -> Map.put(base, :hex, hex)
|
||||
_ -> base
|
||||
end
|
||||
end)
|
||||
|
||||
%{name: opt["name"], type: type, values: values}
|
||||
end)
|
||||
end
|
||||
|
||||
def option_types(%{option_types: option_types}) when is_list(option_types), do: option_types
|
||||
def option_types(_), do: []
|
||||
|
||||
defp option_type_atom("color"), do: :color
|
||||
defp option_type_atom(_), do: :size
|
||||
|
||||
@doc """
|
||||
Generates a checksum from provider data for detecting changes.
|
||||
"""
|
||||
|
||||
@@ -59,8 +59,18 @@ defmodule SimpleshopTheme.Search do
|
||||
"""
|
||||
def index_product(%Product{} = product) do
|
||||
product = Repo.preload(product, [:variants], force: true)
|
||||
remove_from_index(product.id)
|
||||
insert_into_index(product)
|
||||
|
||||
Repo.transaction(fn ->
|
||||
remove_from_index(product.id)
|
||||
insert_into_index(product)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a product from the search index.
|
||||
"""
|
||||
def remove_product(product_id) do
|
||||
remove_from_index(product_id)
|
||||
end
|
||||
|
||||
# Build an FTS5 MATCH query from user input.
|
||||
@@ -126,20 +136,13 @@ defmodule SimpleshopTheme.Search do
|
||||
end
|
||||
|
||||
defp insert_into_index(%Product{} = product) do
|
||||
# Insert mapping row
|
||||
Repo.query!(
|
||||
"INSERT INTO products_search_map (product_id) VALUES (?1)",
|
||||
[product.id]
|
||||
)
|
||||
|
||||
# Get the rowid just inserted
|
||||
%{rows: [[rowid]]} =
|
||||
Repo.query!(
|
||||
"SELECT rowid FROM products_search_map WHERE product_id = ?1",
|
||||
[product.id]
|
||||
)
|
||||
%{rows: [[rowid]]} = Repo.query!("SELECT last_insert_rowid()")
|
||||
|
||||
# Build index content
|
||||
variant_info = build_variant_info(product.variants || [])
|
||||
description = strip_html(product.description || "")
|
||||
|
||||
@@ -153,7 +156,6 @@ defmodule SimpleshopTheme.Search do
|
||||
end
|
||||
|
||||
defp remove_from_index(product_id) do
|
||||
# Get the rowid for this product (if indexed)
|
||||
case Repo.query!(
|
||||
"SELECT rowid FROM products_search_map WHERE product_id = ?1",
|
||||
[product_id]
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
search_results={assigns[:search_results] || []}
|
||||
>
|
||||
<main id="main-content">
|
||||
<.collection_header title="All Products" product_count={length(@preview_data.products)} />
|
||||
<.collection_header title="All Products" product_count={length(assigns[:products] || [])} />
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<.filter_bar categories={@preview_data.categories} />
|
||||
<.filter_bar categories={assigns[:categories] || []} />
|
||||
|
||||
<.product_grid theme_settings={@theme_settings}>
|
||||
<%= for product <- @preview_data.products do %>
|
||||
<%= for product <- assigns[:products] || [] do %>
|
||||
<.product_card
|
||||
product={product}
|
||||
theme_settings={@theme_settings}
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
/>
|
||||
|
||||
<.product_grid columns={:fixed_4} gap="gap-4" class="mt-12 max-w-xl mx-auto">
|
||||
<%= for product <- Enum.take(@preview_data.products, 4) do %>
|
||||
<%= for product <- Enum.take(assigns[:products] || [], 4) do %>
|
||||
<.product_card
|
||||
product={product}
|
||||
theme_settings={@theme_settings}
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
mode={@mode}
|
||||
/>
|
||||
|
||||
<.category_nav categories={@preview_data.categories} mode={@mode} />
|
||||
<.category_nav categories={assigns[:categories] || []} mode={@mode} />
|
||||
|
||||
<.featured_products_section
|
||||
title="Featured products"
|
||||
products={@preview_data.products}
|
||||
products={assigns[:products] || []}
|
||||
theme_settings={@theme_settings}
|
||||
mode={@mode}
|
||||
/>
|
||||
|
||||
@@ -97,7 +97,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
|
||||
{render_slot(@inner_block)}
|
||||
|
||||
<.shop_footer theme_settings={@theme_settings} mode={@mode} />
|
||||
<.shop_footer
|
||||
theme_settings={@theme_settings}
|
||||
mode={@mode}
|
||||
categories={assigns[:categories] || []}
|
||||
/>
|
||||
|
||||
<.cart_drawer
|
||||
cart_items={@cart_items}
|
||||
@@ -341,9 +345,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
assign(
|
||||
assigns,
|
||||
:results_with_images,
|
||||
Enum.map(assigns.search_results, fn product ->
|
||||
assigns.search_results
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {product, idx} ->
|
||||
image = Product.primary_image(product)
|
||||
%{product: product, image_url: ProductImage.direct_url(image, 96)}
|
||||
%{product: product, image_url: ProductImage.direct_url(image, 400), idx: idx}
|
||||
end)
|
||||
)
|
||||
|
||||
@@ -352,6 +358,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
id="search-modal"
|
||||
class="search-modal"
|
||||
style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1001; display: none; align-items: flex-start; justify-content: center; padding-top: 10vh;"
|
||||
phx-hook="SearchModal"
|
||||
phx-click={
|
||||
Phoenix.LiveView.JS.hide(to: "#search-modal")
|
||||
|> Phoenix.LiveView.JS.push("clear_search")
|
||||
@@ -360,10 +367,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
<div
|
||||
class="search-modal-content w-full max-w-xl mx-4"
|
||||
style="background: var(--t-surface-raised); border-radius: var(--t-radius-card); overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);"
|
||||
phx-click-away={
|
||||
Phoenix.LiveView.JS.hide(to: "#search-modal")
|
||||
|> Phoenix.LiveView.JS.push("clear_search")
|
||||
}
|
||||
onclick="event.stopPropagation()"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-3 p-4"
|
||||
@@ -391,10 +395,20 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
placeholder="Search products..."
|
||||
value={@search_query}
|
||||
phx-keyup="search"
|
||||
phx-debounce="300"
|
||||
phx-debounce="150"
|
||||
autocomplete="off"
|
||||
phx-click={Phoenix.LiveView.JS.dispatch("stop-propagation")}
|
||||
role="combobox"
|
||||
aria-expanded={to_string(@search_results != [])}
|
||||
aria-controls="search-results-list"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
<div
|
||||
class="hidden sm:flex items-center gap-1 text-xs px-1.5 py-0.5 rounded"
|
||||
style="color: var(--t-text-tertiary); border: 1px solid var(--t-border-default);"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<kbd>⌘</kbd><kbd>K</kbd>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center transition-all"
|
||||
@@ -423,18 +437,20 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
<div class="search-results" style="max-height: 60vh; overflow-y: auto;">
|
||||
<%= cond do %>
|
||||
<% @search_results != [] -> %>
|
||||
<ul class="py-2" role="listbox" aria-label="Search results">
|
||||
<li :for={item <- @results_with_images} role="option">
|
||||
<a
|
||||
href={"/products/#{item.product.slug || item.product.id}"}
|
||||
<ul id="search-results-list" class="py-2" role="listbox" aria-label="Search results">
|
||||
<li
|
||||
:for={item <- @results_with_images}
|
||||
id={"search-result-#{item.idx}"}
|
||||
role="option"
|
||||
aria-selected="false"
|
||||
>
|
||||
<.link
|
||||
navigate={"/products/#{item.product.slug || item.product.id}"}
|
||||
class="flex items-center gap-3 px-4 py-3 transition-colors"
|
||||
style="text-decoration: none; color: inherit;"
|
||||
onmouseenter="this.style.background='var(--t-surface-sunken)'"
|
||||
onmouseleave="this.style.background='transparent'"
|
||||
phx-click={
|
||||
Phoenix.LiveView.JS.hide(to: "#search-modal")
|
||||
|> Phoenix.LiveView.JS.push("clear_search")
|
||||
}
|
||||
phx-click={Phoenix.LiveView.JS.hide(to: "#search-modal")}
|
||||
>
|
||||
<div
|
||||
:if={item.image_url}
|
||||
@@ -459,7 +475,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
<% String.length(@search_query) >= 2 -> %>
|
||||
@@ -494,12 +510,10 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
"""
|
||||
attr :theme_settings, :map, required: true
|
||||
attr :mode, :atom, default: :live
|
||||
attr :categories, :list, default: []
|
||||
|
||||
def shop_footer(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:current_year, Date.utc_today().year)
|
||||
|> assign(:categories, SimpleshopTheme.Theme.PreviewData.categories())
|
||||
assigns = assign(assigns, :current_year, Date.utc_today().year)
|
||||
|
||||
~H"""
|
||||
<footer style="background-color: var(--t-surface-raised); border-top: 1px solid var(--t-border-default);">
|
||||
|
||||
@@ -696,7 +696,12 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
>
|
||||
<div
|
||||
class="w-24 h-24 rounded-full bg-gray-200 bg-cover bg-center transition-transform hover:scale-105"
|
||||
style={"background-image: url('#{category.image_url}');"}
|
||||
style={
|
||||
if(category[:image_url],
|
||||
do: "background-image: url('#{category.image_url}');",
|
||||
else: ""
|
||||
)
|
||||
}
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
@@ -714,7 +719,12 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
>
|
||||
<div
|
||||
class="w-24 h-24 rounded-full bg-gray-200 bg-cover bg-center transition-transform hover:scale-105"
|
||||
style={"background-image: url('#{category.image_url}');"}
|
||||
style={
|
||||
if(category[:image_url],
|
||||
do: "background-image: url('#{category.image_url}');",
|
||||
else: ""
|
||||
)
|
||||
}
|
||||
>
|
||||
</div>
|
||||
<span
|
||||
|
||||
@@ -9,7 +9,8 @@ defmodule SimpleshopThemeWeb.ErrorHTML do
|
||||
alias SimpleshopTheme.Settings
|
||||
alias SimpleshopTheme.Settings.ThemeSettings
|
||||
alias SimpleshopTheme.Media
|
||||
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator, PreviewData}
|
||||
alias SimpleshopTheme.Products
|
||||
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator}
|
||||
|
||||
def render("404.html", assigns) do
|
||||
render_error_page(
|
||||
@@ -39,10 +40,8 @@ defmodule SimpleshopThemeWeb.ErrorHTML do
|
||||
logo_image = safe_load(&Media.get_logo/0)
|
||||
header_image = safe_load(&Media.get_header/0)
|
||||
|
||||
preview_data = %{
|
||||
products: PreviewData.products(),
|
||||
categories: PreviewData.categories()
|
||||
}
|
||||
products = safe_load(fn -> Products.list_visible_products(limit: 4) end) || []
|
||||
categories = safe_load(fn -> Products.list_categories() end) || []
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
@@ -50,7 +49,8 @@ defmodule SimpleshopThemeWeb.ErrorHTML do
|
||||
|> Map.put(:generated_css, generated_css)
|
||||
|> Map.put(:logo_image, logo_image)
|
||||
|> Map.put(:header_image, header_image)
|
||||
|> Map.put(:preview_data, preview_data)
|
||||
|> Map.put(:products, products)
|
||||
|> Map.put(:categories, categories)
|
||||
|> Map.put(:error_code, error_code)
|
||||
|> Map.put(:error_title, error_title)
|
||||
|> Map.put(:error_description, error_description)
|
||||
@@ -88,7 +88,8 @@ defmodule SimpleshopThemeWeb.ErrorHTML do
|
||||
theme_settings={@theme_settings}
|
||||
logo_image={@logo_image}
|
||||
header_image={@header_image}
|
||||
preview_data={@preview_data}
|
||||
products={@products}
|
||||
categories={@categories}
|
||||
error_code={@error_code}
|
||||
error_title={@error_title}
|
||||
error_description={@error_description}
|
||||
|
||||
@@ -311,6 +311,8 @@ defmodule SimpleshopThemeWeb.Admin.Theme.Index do
|
||||
defp preview_assigns(assigns) do
|
||||
assign(assigns, %{
|
||||
mode: :preview,
|
||||
products: assigns.preview_data.products,
|
||||
categories: assigns.preview_data.categories,
|
||||
cart_items: PreviewData.cart_drawer_items(),
|
||||
cart_count: 2,
|
||||
cart_subtotal: "£72.00"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule SimpleshopThemeWeb.Shop.Collection do
|
||||
use SimpleshopThemeWeb, :live_view
|
||||
|
||||
alias SimpleshopTheme.Theme.PreviewData
|
||||
alias SimpleshopTheme.Products
|
||||
|
||||
@sort_options [
|
||||
{"featured", "Featured"},
|
||||
@@ -16,7 +16,6 @@ defmodule SimpleshopThemeWeb.Shop.Collection do
|
||||
def mount(_params, _session, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:categories, PreviewData.categories())
|
||||
|> assign(:sort_options, @sort_options)
|
||||
|> assign(:current_sort, "featured")
|
||||
|
||||
@@ -27,7 +26,7 @@ defmodule SimpleshopThemeWeb.Shop.Collection do
|
||||
def handle_params(%{"slug" => slug} = params, _uri, socket) do
|
||||
sort = params["sort"] || "featured"
|
||||
|
||||
case load_collection(slug) do
|
||||
case load_collection(slug, sort) do
|
||||
{:ok, title, category, products} ->
|
||||
{:noreply,
|
||||
socket
|
||||
@@ -35,7 +34,7 @@ defmodule SimpleshopThemeWeb.Shop.Collection do
|
||||
|> assign(:collection_title, title)
|
||||
|> assign(:current_category, category)
|
||||
|> assign(:current_sort, sort)
|
||||
|> assign(:products, sort_products(products, sort))}
|
||||
|> assign(:products, products)}
|
||||
|
||||
:not_found ->
|
||||
{:noreply,
|
||||
@@ -45,19 +44,22 @@ defmodule SimpleshopThemeWeb.Shop.Collection do
|
||||
end
|
||||
end
|
||||
|
||||
defp load_collection("all") do
|
||||
{:ok, "All Products", nil, PreviewData.products()}
|
||||
defp load_collection("all", sort) do
|
||||
{:ok, "All Products", nil, Products.list_visible_products(sort: sort)}
|
||||
end
|
||||
|
||||
defp load_collection("sale") do
|
||||
sale_products = Enum.filter(PreviewData.products(), & &1.on_sale)
|
||||
{:ok, "Sale", :sale, sale_products}
|
||||
defp load_collection("sale", sort) do
|
||||
{:ok, "Sale", :sale, Products.list_visible_products(on_sale: true, sort: sort)}
|
||||
end
|
||||
|
||||
defp load_collection(slug) do
|
||||
case PreviewData.category_by_slug(slug) do
|
||||
nil -> :not_found
|
||||
category -> {:ok, category.name, category, PreviewData.products_by_category(slug)}
|
||||
defp load_collection(slug, sort) do
|
||||
case Enum.find(Products.list_categories(), &(&1.slug == slug)) do
|
||||
nil ->
|
||||
:not_found
|
||||
|
||||
category ->
|
||||
products = Products.list_visible_products(category: category.name, sort: sort)
|
||||
{:ok, category.name, category, products}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -73,17 +75,6 @@ defmodule SimpleshopThemeWeb.Shop.Collection do
|
||||
{:noreply, push_patch(socket, to: ~p"/collections/#{slug}?sort=#{sort}")}
|
||||
end
|
||||
|
||||
defp sort_products(products, "featured"), do: products
|
||||
defp sort_products(products, "newest"), do: Enum.reverse(products)
|
||||
defp sort_products(products, "price_asc"), do: Enum.sort_by(products, & &1.cheapest_price)
|
||||
|
||||
defp sort_products(products, "price_desc"),
|
||||
do: Enum.sort_by(products, & &1.cheapest_price, :desc)
|
||||
|
||||
defp sort_products(products, "name_asc"), do: Enum.sort_by(products, & &1.title)
|
||||
defp sort_products(products, "name_desc"), do: Enum.sort_by(products, & &1.title, :desc)
|
||||
defp sort_products(products, _), do: products
|
||||
|
||||
defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}"
|
||||
defp collection_path(slug, sort), do: ~p"/collections/#{slug}?sort=#{sort}"
|
||||
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
defmodule SimpleshopThemeWeb.Shop.Home do
|
||||
use SimpleshopThemeWeb, :live_view
|
||||
|
||||
alias SimpleshopTheme.Theme.PreviewData
|
||||
alias SimpleshopTheme.Products
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
preview_data = %{
|
||||
products: PreviewData.products(),
|
||||
categories: PreviewData.categories()
|
||||
}
|
||||
products = Products.list_visible_products(limit: 8)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Home")
|
||||
|> assign(:preview_data, preview_data)
|
||||
|> assign(:products, products)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@@ -2,61 +2,53 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
|
||||
use SimpleshopThemeWeb, :live_view
|
||||
|
||||
alias SimpleshopTheme.Cart
|
||||
alias SimpleshopTheme.Products
|
||||
alias SimpleshopTheme.Products.{Product, ProductImage}
|
||||
alias SimpleshopTheme.Theme.PreviewData
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
products = PreviewData.products()
|
||||
def mount(%{"id" => slug}, _session, socket) do
|
||||
case Products.get_visible_product(slug) do
|
||||
nil ->
|
||||
{:ok, push_navigate(socket, to: ~p"/collections/all")}
|
||||
|
||||
# Find product by slug or ID (real products use slugs, mock data uses string IDs)
|
||||
product = find_product(products, id)
|
||||
product ->
|
||||
related_products =
|
||||
Products.list_visible_products(
|
||||
category: product.category,
|
||||
limit: 4,
|
||||
exclude: product.id
|
||||
)
|
||||
|
||||
# Get related products (exclude current product, take 4)
|
||||
related_products =
|
||||
products
|
||||
|> Enum.reject(fn p -> p.id == product.id end)
|
||||
|> Enum.take(4)
|
||||
gallery_images =
|
||||
(product.images || [])
|
||||
|> Enum.sort_by(& &1.position)
|
||||
|> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
# Build gallery images from local image_id or external URL
|
||||
gallery_images =
|
||||
(Map.get(product, :images) || [])
|
||||
|> Enum.sort_by(& &1.position)
|
||||
|> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
option_types = Product.option_types(product)
|
||||
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)
|
||||
|
||||
# Initialize variant selection
|
||||
option_types = Product.option_types(product)
|
||||
variants = Map.get(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.title)
|
||||
|> assign(:product, product)
|
||||
|> assign(:gallery_images, gallery_images)
|
||||
|> assign(:related_products, related_products)
|
||||
|> assign(:quantity, 1)
|
||||
|> 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)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, product.title)
|
||||
|> assign(:product, product)
|
||||
|> assign(:gallery_images, gallery_images)
|
||||
|> assign(:related_products, related_products)
|
||||
|> assign(:quantity, 1)
|
||||
|> 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
|
||||
|
||||
# Find product by slug first (real products), then try ID match (mock data)
|
||||
defp find_product(products, id) do
|
||||
Enum.find(products, fn p -> p[:slug] == id end) ||
|
||||
Enum.find(products, fn p -> p.id == id end) ||
|
||||
List.first(products)
|
||||
end
|
||||
|
||||
# Select first available variant by default
|
||||
defp initialize_variant_selection([first | _] = _variants) do
|
||||
{first.options, first}
|
||||
end
|
||||
@@ -65,11 +57,8 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow 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 =
|
||||
@@ -99,10 +88,8 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
|
||||
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,
|
||||
|
||||
@@ -14,7 +14,7 @@ defmodule SimpleshopThemeWeb.ThemeHook do
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
|
||||
alias SimpleshopTheme.{Settings, Media}
|
||||
alias SimpleshopTheme.{Products, Settings, Media}
|
||||
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator}
|
||||
|
||||
def on_mount(:mount_theme, _params, _session, socket) do
|
||||
@@ -37,6 +37,7 @@ defmodule SimpleshopThemeWeb.ThemeHook do
|
||||
|> assign(:generated_css, generated_css)
|
||||
|> assign(:logo_image, Media.get_logo())
|
||||
|> assign(:header_image, Media.get_header())
|
||||
|> assign(:categories, Products.list_categories())
|
||||
|> assign(:mode, :shop)
|
||||
|
||||
{:cont, socket}
|
||||
|
||||
Reference in New Issue
Block a user