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:
jamey
2026-02-13 08:27:26 +00:00
parent 037cd168cd
commit 57c3ba0e28
22 changed files with 745 additions and 330 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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