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:
@@ -9,6 +9,8 @@
|
||||
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
||||
cart_status={assigns[:cart_status]}
|
||||
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">
|
||||
<.page_title text="Your basket" />
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
||||
cart_status={assigns[:cart_status]}
|
||||
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">
|
||||
<%= if @order && @order.payment_status == "paid" do %>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
||||
cart_status={assigns[:cart_status]}
|
||||
active_page="collection"
|
||||
search_query={assigns[:search_query] || ""}
|
||||
search_results={assigns[:search_results] || []}
|
||||
>
|
||||
<main id="main-content">
|
||||
<.collection_header title="All Products" product_count={length(@preview_data.products)} />
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
||||
cart_status={assigns[:cart_status]}
|
||||
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">
|
||||
<.hero_section
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
||||
cart_status={assigns[:cart_status]}
|
||||
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);">
|
||||
<%= if assigns[:hero_background] do %>
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
cart_status={assigns[:cart_status]}
|
||||
active_page="error"
|
||||
error_page
|
||||
search_query={assigns[:search_query] || ""}
|
||||
search_results={assigns[:search_results] || []}
|
||||
>
|
||||
<main
|
||||
id="main-content"
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
||||
cart_status={assigns[:cart_status]}
|
||||
active_page="home"
|
||||
search_query={assigns[:search_query] || ""}
|
||||
search_results={assigns[:search_results] || []}
|
||||
>
|
||||
<main id="main-content">
|
||||
<.hero_section
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
cart_drawer_open={assigns[:cart_drawer_open] || false}
|
||||
cart_status={assigns[:cart_status]}
|
||||
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">
|
||||
<.breadcrumb
|
||||
|
||||
@@ -67,6 +67,8 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
attr :cart_status, :string, default: nil
|
||||
attr :active_page, :string, required: true
|
||||
attr :error_page, :boolean, default: false
|
||||
attr :search_query, :string, default: ""
|
||||
attr :search_results, :list, default: []
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
@@ -106,7 +108,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
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} />
|
||||
</div>
|
||||
@@ -315,35 +321,49 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the search modal overlay.
|
||||
|
||||
This is a modal dialog for searching products. Currently provides
|
||||
the UI shell; search functionality will be added later.
|
||||
Renders the search modal overlay with live search results.
|
||||
|
||||
## Attributes
|
||||
|
||||
* `hint_text` - Optional. Hint text shown below the search input.
|
||||
Defaults to nil (no hint shown).
|
||||
|
||||
## Examples
|
||||
|
||||
<.search_modal />
|
||||
<.search_modal hint_text="Try searching for \"mountain\" or \"forest\"" />
|
||||
* `hint_text` - Hint text shown when no query is entered.
|
||||
* `search_query` - Current search query string.
|
||||
* `search_results` - List of Product structs matching the query.
|
||||
"""
|
||||
attr :hint_text, :string, default: nil
|
||||
attr :search_query, :string, default: ""
|
||||
attr :search_results, :list, default: []
|
||||
|
||||
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"""
|
||||
<div
|
||||
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-click={Phoenix.LiveView.JS.hide(to: "#search-modal")}
|
||||
phx-click={
|
||||
Phoenix.LiveView.JS.hide(to: "#search-modal")
|
||||
|> Phoenix.LiveView.JS.push("clear_search")
|
||||
}
|
||||
>
|
||||
<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")}
|
||||
phx-click-away={
|
||||
Phoenix.LiveView.JS.hide(to: "#search-modal")
|
||||
|> Phoenix.LiveView.JS.push("clear_search")
|
||||
}
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-3 p-4"
|
||||
@@ -365,16 +385,24 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
<input
|
||||
type="text"
|
||||
id="search-input"
|
||||
name="query"
|
||||
class="flex-1 text-lg bg-transparent border-none outline-none"
|
||||
style="font-family: var(--t-font-body); color: var(--t-text-primary);"
|
||||
placeholder="Search products..."
|
||||
value={@search_query}
|
||||
phx-keyup="search"
|
||||
phx-debounce="300"
|
||||
autocomplete="off"
|
||||
phx-click={Phoenix.LiveView.JS.dispatch("stop-propagation")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
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);"
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
@@ -391,11 +419,60 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<%= if @hint_text do %>
|
||||
<div class="p-6" style="color: var(--t-text-tertiary);">
|
||||
<p class="text-sm">{@hint_text}</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<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);">
|
||||
<p class="text-sm">{@hint_text}</p>
|
||||
</div>
|
||||
<% true -> %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
@@ -108,7 +108,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
<% end %>
|
||||
<%= if @has_hover_image do %>
|
||||
<div
|
||||
id={"product-image-scroll-#{@product[:id] || @product.title}"}
|
||||
id={"product-image-scroll-#{Map.get(@product, :id, @product.title)}"}
|
||||
class="product-image-scroll"
|
||||
phx-hook="ProductImageScroll"
|
||||
>
|
||||
@@ -143,7 +143,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
<% end %>
|
||||
</div>
|
||||
<div class={content_padding_class(@variant)}>
|
||||
<%= if @show_category && @product[:category] do %>
|
||||
<%= if @show_category && Map.get(@product, :category) do %>
|
||||
<%= if @mode == :preview do %>
|
||||
<p
|
||||
class="text-xs mb-1"
|
||||
@@ -175,7 +175,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
</a>
|
||||
<% else %>
|
||||
<.link
|
||||
navigate={"/products/#{@product[:slug] || @product[:id]}"}
|
||||
navigate={"/products/#{Map.get(@product, :slug) || Map.get(@product, :id)}"}
|
||||
class="stretched-link"
|
||||
style="color: inherit; text-decoration: none;"
|
||||
>
|
||||
@@ -212,11 +212,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
is_nil(image) ->
|
||||
{nil, nil}
|
||||
|
||||
image[:image_id] ->
|
||||
Map.get(image, :image_id) ->
|
||||
{"/images/#{image.image_id}/variant/", ProductImage.source_width(image)}
|
||||
|
||||
image[:src] ->
|
||||
{image[:src], ProductImage.source_width(image)}
|
||||
Map.get(image, :src) ->
|
||||
{Map.get(image, :src), ProductImage.source_width(image)}
|
||||
|
||||
true ->
|
||||
{nil, nil}
|
||||
@@ -285,9 +285,9 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
<%= cond do %>
|
||||
<% Map.get(@product, :in_stock, true) == false -> %>
|
||||
<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>
|
||||
<% @product[:on_sale] -> %>
|
||||
<% Map.get(@product, :on_sale) -> %>
|
||||
<span class="product-badge badge-sale">Sale</span>
|
||||
<% true -> %>
|
||||
<% end %>
|
||||
|
||||
Reference in New Issue
Block a user