add FTS5 full-text product search
Adds SQLite FTS5 search index with BM25 ranking across product title, category, variant attributes, and description. Search modal now has live results with thumbnails, prices, and click-to-navigate. Index rebuilds automatically after each provider sync. Also fixes Access syntax on Product/ProductImage structs (Map.get instead of bracket notation) which was causing crashes when real products were loaded from the database. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
35e0386abb
commit
037cd168cd
192
lib/simpleshop_theme/search.ex
Normal file
192
lib/simpleshop_theme/search.ex
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
defmodule SimpleshopTheme.Search do
|
||||||
|
@moduledoc """
|
||||||
|
Full-text product search backed by SQLite FTS5.
|
||||||
|
|
||||||
|
Uses a contentless FTS5 index with BM25 ranking. The index is rebuilt
|
||||||
|
from the products table after each provider sync.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Products.Product
|
||||||
|
alias SimpleshopTheme.Repo
|
||||||
|
|
||||||
|
@listing_preloads [images: :image]
|
||||||
|
|
||||||
|
# BM25 column weights: title(10), category(5), variant_info(3), description(1)
|
||||||
|
@bm25_weights "10.0, 5.0, 3.0, 1.0"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Searches products by query string. Returns ranked list of Product structs
|
||||||
|
with listing preloads, or empty list for blank/short queries.
|
||||||
|
"""
|
||||||
|
def search(query) when is_binary(query) do
|
||||||
|
query = String.trim(query)
|
||||||
|
|
||||||
|
if String.length(query) < 2 do
|
||||||
|
[]
|
||||||
|
else
|
||||||
|
fts_query = build_fts_query(query)
|
||||||
|
search_fts(fts_query)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def search(_), do: []
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Rebuilds the entire FTS5 index from visible, active products.
|
||||||
|
"""
|
||||||
|
def rebuild_index do
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
# Clear existing index data
|
||||||
|
Repo.query!("DELETE FROM products_search_map")
|
||||||
|
Repo.query!("DELETE FROM products_search")
|
||||||
|
|
||||||
|
# Load all visible products with variants for indexing
|
||||||
|
products =
|
||||||
|
Product
|
||||||
|
|> where([p], p.visible == true and p.status == "active")
|
||||||
|
|> preload([:variants])
|
||||||
|
|> Repo.all()
|
||||||
|
|
||||||
|
Enum.each(products, &insert_into_index/1)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Indexes or reindexes a single product.
|
||||||
|
Removes existing entry first if present.
|
||||||
|
"""
|
||||||
|
def index_product(%Product{} = product) do
|
||||||
|
product = Repo.preload(product, [:variants], force: true)
|
||||||
|
remove_from_index(product.id)
|
||||||
|
insert_into_index(product)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Build an FTS5 MATCH query from user input.
|
||||||
|
# Strips special chars, splits into tokens, adds * prefix match to last token.
|
||||||
|
defp build_fts_query(input) do
|
||||||
|
tokens =
|
||||||
|
input
|
||||||
|
|> String.replace(~r/[^\w\s]/, "")
|
||||||
|
|> String.split(~r/\s+/, trim: true)
|
||||||
|
|
||||||
|
case tokens do
|
||||||
|
[] ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
tokens ->
|
||||||
|
{complete, [last]} = Enum.split(tokens, -1)
|
||||||
|
|
||||||
|
parts =
|
||||||
|
Enum.map(complete, &~s("#{&1}")) ++
|
||||||
|
[~s("#{last}" *)]
|
||||||
|
|
||||||
|
Enum.join(parts, " ")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp search_fts(nil), do: []
|
||||||
|
|
||||||
|
defp search_fts(fts_query) do
|
||||||
|
result =
|
||||||
|
Repo.query!(
|
||||||
|
"""
|
||||||
|
SELECT m.product_id, bm25(products_search, #{@bm25_weights}) AS rank
|
||||||
|
FROM products_search
|
||||||
|
JOIN products_search_map m ON m.rowid = products_search.rowid
|
||||||
|
WHERE products_search MATCH ?1
|
||||||
|
ORDER BY rank
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
[fts_query]
|
||||||
|
)
|
||||||
|
|
||||||
|
product_ids = Enum.map(result.rows, fn [id, _rank] -> id end)
|
||||||
|
|
||||||
|
if product_ids == [] do
|
||||||
|
[]
|
||||||
|
else
|
||||||
|
# Fetch full structs preserving rank order
|
||||||
|
products_by_id =
|
||||||
|
Product
|
||||||
|
|> where([p], p.id in ^product_ids)
|
||||||
|
|> where([p], p.visible == true and p.status == "active")
|
||||||
|
|> preload(^@listing_preloads)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Map.new(&{&1.id, &1})
|
||||||
|
|
||||||
|
Enum.flat_map(product_ids, fn id ->
|
||||||
|
case Map.get(products_by_id, id) do
|
||||||
|
nil -> []
|
||||||
|
product -> [product]
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
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]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build index content
|
||||||
|
variant_info = build_variant_info(product.variants || [])
|
||||||
|
description = strip_html(product.description || "")
|
||||||
|
|
||||||
|
Repo.query!(
|
||||||
|
"""
|
||||||
|
INSERT INTO products_search (rowid, title, category, variant_info, description)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
""",
|
||||||
|
[rowid, product.title || "", product.category || "", variant_info, description]
|
||||||
|
)
|
||||||
|
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]
|
||||||
|
) do
|
||||||
|
%{rows: [[rowid]]} ->
|
||||||
|
Repo.query!("DELETE FROM products_search WHERE rowid = ?1", [rowid])
|
||||||
|
Repo.query!("DELETE FROM products_search_map WHERE rowid = ?1", [rowid])
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Build searchable variant text from enabled variants
|
||||||
|
defp build_variant_info(variants) do
|
||||||
|
variants
|
||||||
|
|> Enum.filter(& &1.is_enabled)
|
||||||
|
|> Enum.flat_map(fn v -> [v.title | Map.values(v.options || %{})] end)
|
||||||
|
|> Enum.uniq()
|
||||||
|
|> Enum.join(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Strip HTML tags and decode common entities
|
||||||
|
defp strip_html(html) do
|
||||||
|
html
|
||||||
|
|> String.replace(~r/<[^>]+>/, " ")
|
||||||
|
|> String.replace("&", "&")
|
||||||
|
|> String.replace("<", "<")
|
||||||
|
|> String.replace(">", ">")
|
||||||
|
|> String.replace(""", "\"")
|
||||||
|
|> String.replace("'", "'")
|
||||||
|
|> String.replace(" ", " ")
|
||||||
|
|> String.replace(~r/\s+/, " ")
|
||||||
|
|> String.trim()
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -99,6 +99,10 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
|
|||||||
Products.update_sync_status(conn, "completed", DateTime.utc_now())
|
Products.update_sync_status(conn, "completed", DateTime.utc_now())
|
||||||
product_count = Products.count_products_for_connection(conn.id)
|
product_count = Products.count_products_for_connection(conn.id)
|
||||||
broadcast_sync(conn.id, {:sync_status, "completed", product_count})
|
broadcast_sync(conn.id, {:sync_status, "completed", product_count})
|
||||||
|
|
||||||
|
# Rebuild search index after successful sync
|
||||||
|
SimpleshopTheme.Search.rebuild_index()
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
else
|
else
|
||||||
{:error, reason} = error ->
|
{:error, reason} = error ->
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
||||||
cart_status={assigns[:cart_status]}
|
cart_status={assigns[:cart_status]}
|
||||||
active_page="cart"
|
active_page="cart"
|
||||||
|
search_query={assigns[:search_query] || ""}
|
||||||
|
search_results={assigns[:search_results] || []}
|
||||||
>
|
>
|
||||||
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<.page_title text="Your basket" />
|
<.page_title text="Your basket" />
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
||||||
cart_status={assigns[:cart_status]}
|
cart_status={assigns[:cart_status]}
|
||||||
active_page="checkout"
|
active_page="checkout"
|
||||||
|
search_query={assigns[:search_query] || ""}
|
||||||
|
search_results={assigns[:search_results] || []}
|
||||||
>
|
>
|
||||||
<main id="main-content" class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
<main id="main-content" class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||||
<%= if @order && @order.payment_status == "paid" do %>
|
<%= if @order && @order.payment_status == "paid" do %>
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
||||||
cart_status={assigns[:cart_status]}
|
cart_status={assigns[:cart_status]}
|
||||||
active_page="collection"
|
active_page="collection"
|
||||||
|
search_query={assigns[:search_query] || ""}
|
||||||
|
search_results={assigns[:search_results] || []}
|
||||||
>
|
>
|
||||||
<main id="main-content">
|
<main id="main-content">
|
||||||
<.collection_header title="All Products" product_count={length(@preview_data.products)} />
|
<.collection_header title="All Products" product_count={length(@preview_data.products)} />
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
||||||
cart_status={assigns[:cart_status]}
|
cart_status={assigns[:cart_status]}
|
||||||
active_page="contact"
|
active_page="contact"
|
||||||
|
search_query={assigns[:search_query] || ""}
|
||||||
|
search_results={assigns[:search_results] || []}
|
||||||
>
|
>
|
||||||
<main id="main-content" class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-16">
|
<main id="main-content" class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-16">
|
||||||
<.hero_section
|
<.hero_section
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
||||||
cart_status={assigns[:cart_status]}
|
cart_status={assigns[:cart_status]}
|
||||||
active_page={@active_page}
|
active_page={@active_page}
|
||||||
|
search_query={assigns[:search_query] || ""}
|
||||||
|
search_results={assigns[:search_results] || []}
|
||||||
>
|
>
|
||||||
<main id="main-content" class="content-page" style="background-color: var(--t-surface-base);">
|
<main id="main-content" class="content-page" style="background-color: var(--t-surface-base);">
|
||||||
<%= if assigns[:hero_background] do %>
|
<%= if assigns[:hero_background] do %>
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
cart_status={assigns[:cart_status]}
|
cart_status={assigns[:cart_status]}
|
||||||
active_page="error"
|
active_page="error"
|
||||||
error_page
|
error_page
|
||||||
|
search_query={assigns[:search_query] || ""}
|
||||||
|
search_results={assigns[:search_results] || []}
|
||||||
>
|
>
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
||||||
cart_status={assigns[:cart_status]}
|
cart_status={assigns[:cart_status]}
|
||||||
active_page="home"
|
active_page="home"
|
||||||
|
search_query={assigns[:search_query] || ""}
|
||||||
|
search_results={assigns[:search_results] || []}
|
||||||
>
|
>
|
||||||
<main id="main-content">
|
<main id="main-content">
|
||||||
<.hero_section
|
<.hero_section
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
||||||
cart_status={assigns[:cart_status]}
|
cart_status={assigns[:cart_status]}
|
||||||
active_page="pdp"
|
active_page="pdp"
|
||||||
|
search_query={assigns[:search_query] || ""}
|
||||||
|
search_results={assigns[:search_results] || []}
|
||||||
>
|
>
|
||||||
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<.breadcrumb
|
<.breadcrumb
|
||||||
|
|||||||
@ -67,6 +67,8 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
|||||||
attr :cart_status, :string, default: nil
|
attr :cart_status, :string, default: nil
|
||||||
attr :active_page, :string, required: true
|
attr :active_page, :string, required: true
|
||||||
attr :error_page, :boolean, default: false
|
attr :error_page, :boolean, default: false
|
||||||
|
attr :search_query, :string, default: ""
|
||||||
|
attr :search_results, :list, default: []
|
||||||
|
|
||||||
slot :inner_block, required: true
|
slot :inner_block, required: true
|
||||||
|
|
||||||
@ -106,7 +108,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
|||||||
cart_status={@cart_status}
|
cart_status={@cart_status}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<.search_modal hint_text={~s(Try a search – e.g. "mountain" or "notebook")} />
|
<.search_modal
|
||||||
|
hint_text={~s(Try a search – e.g. "mountain" or "notebook")}
|
||||||
|
search_query={@search_query}
|
||||||
|
search_results={@search_results}
|
||||||
|
/>
|
||||||
|
|
||||||
<.mobile_bottom_nav :if={!@error_page} active_page={@active_page} mode={@mode} />
|
<.mobile_bottom_nav :if={!@error_page} active_page={@active_page} mode={@mode} />
|
||||||
</div>
|
</div>
|
||||||
@ -315,35 +321,49 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders the search modal overlay.
|
Renders the search modal overlay with live search results.
|
||||||
|
|
||||||
This is a modal dialog for searching products. Currently provides
|
|
||||||
the UI shell; search functionality will be added later.
|
|
||||||
|
|
||||||
## Attributes
|
## Attributes
|
||||||
|
|
||||||
* `hint_text` - Optional. Hint text shown below the search input.
|
* `hint_text` - Hint text shown when no query is entered.
|
||||||
Defaults to nil (no hint shown).
|
* `search_query` - Current search query string.
|
||||||
|
* `search_results` - List of Product structs matching the query.
|
||||||
## Examples
|
|
||||||
|
|
||||||
<.search_modal />
|
|
||||||
<.search_modal hint_text="Try searching for \"mountain\" or \"forest\"" />
|
|
||||||
"""
|
"""
|
||||||
attr :hint_text, :string, default: nil
|
attr :hint_text, :string, default: nil
|
||||||
|
attr :search_query, :string, default: ""
|
||||||
|
attr :search_results, :list, default: []
|
||||||
|
|
||||||
def search_modal(assigns) do
|
def search_modal(assigns) do
|
||||||
|
alias SimpleshopTheme.Cart
|
||||||
|
alias SimpleshopTheme.Products.{Product, ProductImage}
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assign(
|
||||||
|
assigns,
|
||||||
|
:results_with_images,
|
||||||
|
Enum.map(assigns.search_results, fn product ->
|
||||||
|
image = Product.primary_image(product)
|
||||||
|
%{product: product, image_url: ProductImage.direct_url(image, 96)}
|
||||||
|
end)
|
||||||
|
)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div
|
<div
|
||||||
id="search-modal"
|
id="search-modal"
|
||||||
class="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;"
|
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-click={Phoenix.LiveView.JS.hide(to: "#search-modal")}
|
phx-click={
|
||||||
|
Phoenix.LiveView.JS.hide(to: "#search-modal")
|
||||||
|
|> Phoenix.LiveView.JS.push("clear_search")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="search-modal-content w-full max-w-xl mx-4"
|
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);"
|
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")}
|
phx-click-away={
|
||||||
|
Phoenix.LiveView.JS.hide(to: "#search-modal")
|
||||||
|
|> Phoenix.LiveView.JS.push("clear_search")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-3 p-4"
|
class="flex items-center gap-3 p-4"
|
||||||
@ -365,16 +385,24 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="search-input"
|
id="search-input"
|
||||||
|
name="query"
|
||||||
class="flex-1 text-lg bg-transparent border-none outline-none"
|
class="flex-1 text-lg bg-transparent border-none outline-none"
|
||||||
style="font-family: var(--t-font-body); color: var(--t-text-primary);"
|
style="font-family: var(--t-font-body); color: var(--t-text-primary);"
|
||||||
placeholder="Search products..."
|
placeholder="Search products..."
|
||||||
|
value={@search_query}
|
||||||
|
phx-keyup="search"
|
||||||
|
phx-debounce="300"
|
||||||
|
autocomplete="off"
|
||||||
phx-click={Phoenix.LiveView.JS.dispatch("stop-propagation")}
|
phx-click={Phoenix.LiveView.JS.dispatch("stop-propagation")}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-8 h-8 flex items-center justify-center transition-all"
|
class="w-8 h-8 flex items-center justify-center transition-all"
|
||||||
style="color: var(--t-text-tertiary); background: none; border: none; cursor: pointer; border-radius: var(--t-radius-button);"
|
style="color: var(--t-text-tertiary); background: none; border: none; cursor: pointer; border-radius: var(--t-radius-button);"
|
||||||
phx-click={Phoenix.LiveView.JS.hide(to: "#search-modal")}
|
phx-click={
|
||||||
|
Phoenix.LiveView.JS.hide(to: "#search-modal")
|
||||||
|
|> Phoenix.LiveView.JS.push("clear_search")
|
||||||
|
}
|
||||||
aria-label="Close search"
|
aria-label="Close search"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -391,13 +419,62 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<%= if @hint_text 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}"}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:if={item.image_url}
|
||||||
|
class="w-12 h-12 flex-shrink-0 rounded overflow-hidden"
|
||||||
|
style="background: var(--t-surface-sunken);"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={item.image_url}
|
||||||
|
alt={item.product.title}
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium truncate" style="color: var(--t-text-primary);">
|
||||||
|
{item.product.title}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs" style="color: var(--t-text-tertiary);">
|
||||||
|
{item.product.category}
|
||||||
|
<span style="margin-left: 0.5rem;">
|
||||||
|
{Cart.format_price(item.product.cheapest_price)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<% String.length(@search_query) >= 2 -> %>
|
||||||
|
<div class="p-6" style="color: var(--t-text-tertiary);">
|
||||||
|
<p class="text-sm">No products found for "{@search_query}"</p>
|
||||||
|
</div>
|
||||||
|
<% @hint_text != nil -> %>
|
||||||
<div class="p-6" style="color: var(--t-text-tertiary);">
|
<div class="p-6" style="color: var(--t-text-tertiary);">
|
||||||
<p class="text-sm">{@hint_text}</p>
|
<p class="text-sm">{@hint_text}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<% true -> %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -108,7 +108,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
|||||||
<% end %>
|
<% end %>
|
||||||
<%= if @has_hover_image do %>
|
<%= if @has_hover_image do %>
|
||||||
<div
|
<div
|
||||||
id={"product-image-scroll-#{@product[:id] || @product.title}"}
|
id={"product-image-scroll-#{Map.get(@product, :id, @product.title)}"}
|
||||||
class="product-image-scroll"
|
class="product-image-scroll"
|
||||||
phx-hook="ProductImageScroll"
|
phx-hook="ProductImageScroll"
|
||||||
>
|
>
|
||||||
@ -143,7 +143,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class={content_padding_class(@variant)}>
|
<div class={content_padding_class(@variant)}>
|
||||||
<%= if @show_category && @product[:category] do %>
|
<%= if @show_category && Map.get(@product, :category) do %>
|
||||||
<%= if @mode == :preview do %>
|
<%= if @mode == :preview do %>
|
||||||
<p
|
<p
|
||||||
class="text-xs mb-1"
|
class="text-xs mb-1"
|
||||||
@ -175,7 +175,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
|||||||
</a>
|
</a>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.link
|
<.link
|
||||||
navigate={"/products/#{@product[:slug] || @product[:id]}"}
|
navigate={"/products/#{Map.get(@product, :slug) || Map.get(@product, :id)}"}
|
||||||
class="stretched-link"
|
class="stretched-link"
|
||||||
style="color: inherit; text-decoration: none;"
|
style="color: inherit; text-decoration: none;"
|
||||||
>
|
>
|
||||||
@ -212,11 +212,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
|||||||
is_nil(image) ->
|
is_nil(image) ->
|
||||||
{nil, nil}
|
{nil, nil}
|
||||||
|
|
||||||
image[:image_id] ->
|
Map.get(image, :image_id) ->
|
||||||
{"/images/#{image.image_id}/variant/", ProductImage.source_width(image)}
|
{"/images/#{image.image_id}/variant/", ProductImage.source_width(image)}
|
||||||
|
|
||||||
image[:src] ->
|
Map.get(image, :src) ->
|
||||||
{image[:src], ProductImage.source_width(image)}
|
{Map.get(image, :src), ProductImage.source_width(image)}
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
{nil, nil}
|
{nil, nil}
|
||||||
@ -285,9 +285,9 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
|||||||
<%= cond do %>
|
<%= cond do %>
|
||||||
<% Map.get(@product, :in_stock, true) == false -> %>
|
<% Map.get(@product, :in_stock, true) == false -> %>
|
||||||
<span class="product-badge badge-sold-out">Sold out</span>
|
<span class="product-badge badge-sold-out">Sold out</span>
|
||||||
<% @product[:is_new] -> %>
|
<% Map.get(@product, :is_new) -> %>
|
||||||
<span class="product-badge badge-new">New</span>
|
<span class="product-badge badge-new">New</span>
|
||||||
<% @product[:on_sale] -> %>
|
<% Map.get(@product, :on_sale) -> %>
|
||||||
<span class="product-badge badge-sale">Sale</span>
|
<span class="product-badge badge-sale">Sale</span>
|
||||||
<% true -> %>
|
<% true -> %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@ -337,8 +337,8 @@ defmodule SimpleshopThemeWeb.Admin.Theme.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] || []
|
option_types = Map.get(product, :option_types) || []
|
||||||
variants = product[:variants] || []
|
variants = Map.get(product, :variants) || []
|
||||||
|
|
||||||
{selected_options, selected_variant} =
|
{selected_options, selected_variant} =
|
||||||
case variants do
|
case variants do
|
||||||
@ -468,7 +468,7 @@ defmodule SimpleshopThemeWeb.Admin.Theme.Index do
|
|||||||
defp build_gallery_images(product) do
|
defp build_gallery_images(product) do
|
||||||
alias SimpleshopTheme.Products.ProductImage
|
alias SimpleshopTheme.Products.ProductImage
|
||||||
|
|
||||||
(product[:images] || [])
|
(Map.get(product, :images) || [])
|
||||||
|> Enum.sort_by(& &1.position)
|
|> Enum.sort_by(& &1.position)
|
||||||
|> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
|
|> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
|
||||||
|> Enum.reject(&is_nil/1)
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|||||||
@ -101,6 +101,8 @@ defmodule SimpleshopThemeWeb.Shop.Collection do
|
|||||||
cart_drawer_open={@cart_drawer_open}
|
cart_drawer_open={@cart_drawer_open}
|
||||||
cart_status={assigns[:cart_status]}
|
cart_status={assigns[:cart_status]}
|
||||||
active_page="collection"
|
active_page="collection"
|
||||||
|
search_query={assigns[:search_query] || ""}
|
||||||
|
search_results={assigns[:search_results] || []}
|
||||||
>
|
>
|
||||||
<main id="main-content">
|
<main id="main-content">
|
||||||
<.collection_header
|
<.collection_header
|
||||||
|
|||||||
@ -20,14 +20,14 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
|
|||||||
|
|
||||||
# Build gallery images from local image_id or external URL
|
# Build gallery images from local image_id or external URL
|
||||||
gallery_images =
|
gallery_images =
|
||||||
(product[:images] || [])
|
(Map.get(product, :images) || [])
|
||||||
|> Enum.sort_by(& &1.position)
|
|> Enum.sort_by(& &1.position)
|
||||||
|> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
|
|> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
|
||||||
|> Enum.reject(&is_nil/1)
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|
||||||
# Initialize variant selection
|
# Initialize variant selection
|
||||||
option_types = Product.option_types(product)
|
option_types = Product.option_types(product)
|
||||||
variants = product[:variants] || []
|
variants = Map.get(product, :variants) || []
|
||||||
{selected_options, selected_variant} = initialize_variant_selection(variants)
|
{selected_options, selected_variant} = initialize_variant_selection(variants)
|
||||||
available_options = compute_available_options(option_types, variants, selected_options)
|
available_options = compute_available_options(option_types, variants, selected_options)
|
||||||
display_price = variant_price(selected_variant, product)
|
display_price = variant_price(selected_variant, product)
|
||||||
|
|||||||
@ -50,7 +50,8 @@ defmodule SimpleshopThemeWeb.Router do
|
|||||||
{SimpleshopThemeWeb.UserAuth, :mount_current_scope},
|
{SimpleshopThemeWeb.UserAuth, :mount_current_scope},
|
||||||
{SimpleshopThemeWeb.ThemeHook, :mount_theme},
|
{SimpleshopThemeWeb.ThemeHook, :mount_theme},
|
||||||
{SimpleshopThemeWeb.ThemeHook, :require_site_live},
|
{SimpleshopThemeWeb.ThemeHook, :require_site_live},
|
||||||
{SimpleshopThemeWeb.CartHook, :mount_cart}
|
{SimpleshopThemeWeb.CartHook, :mount_cart},
|
||||||
|
{SimpleshopThemeWeb.SearchHook, :mount_search}
|
||||||
] do
|
] do
|
||||||
live "/", Shop.Home, :index
|
live "/", Shop.Home, :index
|
||||||
live "/about", Shop.Content, :about
|
live "/about", Shop.Content, :about
|
||||||
|
|||||||
49
lib/simpleshop_theme_web/search_hook.ex
Normal file
49
lib/simpleshop_theme_web/search_hook.ex
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
defmodule SimpleshopThemeWeb.SearchHook do
|
||||||
|
@moduledoc """
|
||||||
|
LiveView on_mount hook for product search.
|
||||||
|
|
||||||
|
Mounted in the public_shop live_session to give all shop LiveViews
|
||||||
|
search state and shared event handlers via attach_hook.
|
||||||
|
|
||||||
|
Handles these events:
|
||||||
|
- `search` - run FTS5 search with debounced query
|
||||||
|
- `clear_search` - reset search state
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
import Phoenix.LiveView, only: [attach_hook: 4]
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Search
|
||||||
|
|
||||||
|
def on_mount(:mount_search, _params, _session, socket) do
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:search_query, "")
|
||||||
|
|> assign(:search_results, [])
|
||||||
|
|> attach_hook(:search_events, :handle_event, &handle_search_event/3)
|
||||||
|
|
||||||
|
{:cont, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_search_event("search", %{"value" => query}, socket) do
|
||||||
|
results = Search.search(query)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:search_query, query)
|
||||||
|
|> assign(:search_results, results)
|
||||||
|
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_search_event("clear_search", _params, socket) do
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:search_query, "")
|
||||||
|
|> assign(:search_results, [])
|
||||||
|
|
||||||
|
{:halt, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_search_event(_event, _params, socket), do: {:cont, socket}
|
||||||
|
end
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
defmodule SimpleshopTheme.Repo.Migrations.CreateProductsSearch do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
# Rowid mapping (FTS5 needs integer rowids, products use UUIDs)
|
||||||
|
execute """
|
||||||
|
CREATE TABLE products_search_map (
|
||||||
|
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
product_id TEXT NOT NULL UNIQUE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# FTS5 virtual table — stores its own content for simple delete/reindex
|
||||||
|
execute """
|
||||||
|
CREATE VIRTUAL TABLE products_search USING fts5(
|
||||||
|
title,
|
||||||
|
category,
|
||||||
|
variant_info,
|
||||||
|
description,
|
||||||
|
tokenize='unicode61 remove_diacritics 2'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
execute "DROP TABLE IF EXISTS products_search"
|
||||||
|
execute "DROP TABLE IF EXISTS products_search_map"
|
||||||
|
end
|
||||||
|
end
|
||||||
199
test/simpleshop_theme/search_test.exs
Normal file
199
test/simpleshop_theme/search_test.exs
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
defmodule SimpleshopTheme.SearchTest do
|
||||||
|
use SimpleshopTheme.DataCase, async: false
|
||||||
|
|
||||||
|
alias SimpleshopTheme.Search
|
||||||
|
|
||||||
|
import SimpleshopTheme.ProductsFixtures
|
||||||
|
|
||||||
|
setup do
|
||||||
|
conn = provider_connection_fixture()
|
||||||
|
|
||||||
|
mountain =
|
||||||
|
product_fixture(%{
|
||||||
|
provider_connection: conn,
|
||||||
|
title: "Mountain Sunrise Art Print",
|
||||||
|
description: "<p>A beautiful <strong>mountain</strong> landscape at dawn.</p>",
|
||||||
|
category: "Art Prints"
|
||||||
|
})
|
||||||
|
|
||||||
|
product_variant_fixture(%{
|
||||||
|
product: mountain,
|
||||||
|
title: "Small / Navy",
|
||||||
|
options: %{"Size" => "Small", "Color" => "Navy"},
|
||||||
|
price: 1999
|
||||||
|
})
|
||||||
|
|
||||||
|
ocean =
|
||||||
|
product_fixture(%{
|
||||||
|
provider_connection: conn,
|
||||||
|
title: "Ocean Waves Notebook",
|
||||||
|
description: "A spiral-bound notebook with ocean wave cover art.",
|
||||||
|
category: "Stationery"
|
||||||
|
})
|
||||||
|
|
||||||
|
product_variant_fixture(%{
|
||||||
|
product: ocean,
|
||||||
|
title: "A5",
|
||||||
|
options: %{"Size" => "A5"},
|
||||||
|
price: 1299
|
||||||
|
})
|
||||||
|
|
||||||
|
forest =
|
||||||
|
product_fixture(%{
|
||||||
|
provider_connection: conn,
|
||||||
|
title: "Forest Silhouette T-Shirt",
|
||||||
|
description: "Cotton t-shirt with forest silhouette print.",
|
||||||
|
category: "Apparel"
|
||||||
|
})
|
||||||
|
|
||||||
|
product_variant_fixture(%{
|
||||||
|
product: forest,
|
||||||
|
title: "Large / Black",
|
||||||
|
options: %{"Size" => "Large", "Color" => "Black"},
|
||||||
|
price: 2999
|
||||||
|
})
|
||||||
|
|
||||||
|
product_variant_fixture(%{
|
||||||
|
product: forest,
|
||||||
|
title: "Medium / Navy Blue",
|
||||||
|
options: %{"Size" => "Medium", "Color" => "Navy Blue"},
|
||||||
|
price: 2999
|
||||||
|
})
|
||||||
|
|
||||||
|
# Hidden product — should not appear in search
|
||||||
|
_hidden =
|
||||||
|
product_fixture(%{
|
||||||
|
provider_connection: conn,
|
||||||
|
title: "Hidden Mountain Poster",
|
||||||
|
description: "This should not be searchable.",
|
||||||
|
category: "Art Prints",
|
||||||
|
visible: false
|
||||||
|
})
|
||||||
|
|
||||||
|
Search.rebuild_index()
|
||||||
|
|
||||||
|
%{mountain: mountain, ocean: ocean, forest: forest}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "search/1" do
|
||||||
|
test "finds products by title", %{mountain: mountain} do
|
||||||
|
results = Search.search("mountain")
|
||||||
|
assert length(results) >= 1
|
||||||
|
assert Enum.any?(results, &(&1.id == mountain.id))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "finds products by category", %{mountain: mountain, ocean: ocean} do
|
||||||
|
results = Search.search("art prints")
|
||||||
|
assert Enum.any?(results, &(&1.id == mountain.id))
|
||||||
|
refute Enum.any?(results, &(&1.id == ocean.id))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "finds products by variant attributes", %{forest: forest} do
|
||||||
|
results = Search.search("navy")
|
||||||
|
assert Enum.any?(results, &(&1.id == forest.id))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "finds products by description", %{mountain: mountain} do
|
||||||
|
results = Search.search("landscape")
|
||||||
|
assert Enum.any?(results, &(&1.id == mountain.id))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "prefix matching on last token", %{mountain: mountain} do
|
||||||
|
results = Search.search("mou")
|
||||||
|
assert Enum.any?(results, &(&1.id == mountain.id))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "multi-word query matches all tokens", %{mountain: mountain} do
|
||||||
|
results = Search.search("mountain sunrise")
|
||||||
|
assert Enum.any?(results, &(&1.id == mountain.id))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty list for blank query" do
|
||||||
|
assert Search.search("") == []
|
||||||
|
assert Search.search(" ") == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty list for single character" do
|
||||||
|
assert Search.search("a") == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty list for nil" do
|
||||||
|
assert Search.search(nil) == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty list for no matches" do
|
||||||
|
assert Search.search("xyznonexistent") == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "special characters don't crash" do
|
||||||
|
assert Search.search("mountain's") == Search.search("mountains")
|
||||||
|
assert Search.search("test & foo") == []
|
||||||
|
assert Search.search("(brackets)") == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "hidden products are excluded" do
|
||||||
|
results = Search.search("hidden")
|
||||||
|
assert results == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "strips HTML from descriptions", %{mountain: mountain} do
|
||||||
|
results = Search.search("landscape dawn")
|
||||||
|
assert Enum.any?(results, &(&1.id == mountain.id))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "results include listing preloads", %{mountain: mountain} do
|
||||||
|
[result | _] = Search.search("mountain sunrise")
|
||||||
|
assert result.id == mountain.id
|
||||||
|
assert Ecto.assoc_loaded?(result.images)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "title matches rank higher than description matches" do
|
||||||
|
results = Search.search("mountain")
|
||||||
|
# Mountain Sunrise Art Print (title match) should rank above
|
||||||
|
# Forest T-Shirt if it happened to mention "mountain" in description
|
||||||
|
first = List.first(results)
|
||||||
|
assert first.title =~ "Mountain"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "rebuild_index/0" do
|
||||||
|
test "rebuilds from scratch" do
|
||||||
|
# Verify search works before rebuild
|
||||||
|
assert length(Search.search("mountain")) >= 1
|
||||||
|
|
||||||
|
# Rebuild and verify still works
|
||||||
|
Search.rebuild_index()
|
||||||
|
assert length(Search.search("mountain")) >= 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "index_product/1" do
|
||||||
|
test "indexes a single product", %{ocean: ocean} do
|
||||||
|
# Clear and rebuild without ocean
|
||||||
|
SimpleshopTheme.Repo.query!("DELETE FROM products_search_map")
|
||||||
|
SimpleshopTheme.Repo.query!("DELETE FROM products_search")
|
||||||
|
|
||||||
|
assert Search.search("ocean") == []
|
||||||
|
|
||||||
|
# Index just ocean
|
||||||
|
ocean = SimpleshopTheme.Repo.preload(ocean, [:variants])
|
||||||
|
Search.index_product(ocean)
|
||||||
|
|
||||||
|
results = Search.search("ocean")
|
||||||
|
assert length(results) == 1
|
||||||
|
assert hd(results).id == ocean.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "reindexing updates existing entry", %{mountain: mountain} do
|
||||||
|
# Change title and reindex
|
||||||
|
mountain =
|
||||||
|
mountain
|
||||||
|
|> Ecto.Changeset.change(title: "Alpine Sunrise Art Print")
|
||||||
|
|> SimpleshopTheme.Repo.update!()
|
||||||
|
|
||||||
|
Search.index_product(mountain)
|
||||||
|
|
||||||
|
assert Search.search("alpine") != []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user