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]