From 57c3ba0e286086cc1348bc1616fb0a656157c3f9 Mon Sep 17 00:00:00 2001 From: jamey Date: Fri, 13 Feb 2026 08:27:26 +0000 Subject: [PATCH] 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 --- PROGRESS.md | 33 +- assets/js/app.js | 115 +++++- lib/simpleshop_theme/cart.ex | 37 +- lib/simpleshop_theme/products/product.ex | 23 +- lib/simpleshop_theme/search.ex | 24 +- .../page_templates/collection.html.heex | 6 +- .../components/page_templates/error.html.heex | 2 +- .../components/page_templates/home.html.heex | 4 +- .../components/shop_components/layout.ex | 58 ++-- .../components/shop_components/product.ex | 14 +- .../controllers/error_html.ex | 15 +- .../live/admin/theme/index.ex | 2 + .../live/shop/collection.ex | 39 +-- lib/simpleshop_theme_web/live/shop/home.ex | 9 +- .../live/shop/product_show.ex | 87 ++--- lib/simpleshop_theme_web/theme_hook.ex | 3 +- .../products/product_test.exs | 18 +- test/simpleshop_theme/search_test.exs | 14 + .../live/shop/collection_test.exs | 74 ++-- .../live/shop/home_test.exs | 39 ++- .../live/shop/product_show_test.exs | 328 ++++++++++++------ .../live/shop/search_integration_test.exs | 131 +++++++ 22 files changed, 745 insertions(+), 330 deletions(-) create mode 100644 test/simpleshop_theme_web/live/shop/search_integration_test.exs diff --git a/PROGRESS.md b/PROGRESS.md index 673933a..129a87a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -16,7 +16,8 @@ - Stripe Checkout with order persistence and webhook handling - Admin credentials page with guided Stripe setup flow - Encrypted settings for API keys and secrets -- Search modal with keyboard shortcut +- FTS5 full-text search with live results modal +- Denormalized product fields (cheapest_price, in_stock, on_sale) for DB-level sort/filter - Transactional emails (order confirmation, shipping notification) - Demo content polished and ready for production @@ -26,7 +27,7 @@ Ordered by dependency level — admin shell chain first (unblocks most downstream work). -Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [search.md](docs/plans/search.md) +Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | # | Task | Depends on | Est | Status | |---|------|------------|-----|--------| @@ -45,11 +46,10 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [setup-wizard.md](doc | ~~12~~ | ~~Consolidate settings page~~ | 6, 7 | 2-3h | done | | ~~13~~ | ~~Admin dashboard (+ setup checklist)~~ | 6, 7, 9 | 2h | done | | ~~15~~ | ~~Setup wizard + admin tests~~ | 13 | 1.5h | done | +| ~~5~~ | ~~Search (functional search with results)~~ | — | 3-4h | done | +| ~~17~~ | ~~Wire shop LiveViews to DB queries (replace PreviewData indirection)~~ | — | 2-3h | done | | | **Next up** | | | | -| 5 | Search (functional search with results) | — | 3-4h | | -| | **Needs admin stable** | | | | | 16 | Variant refinement with live data | — | 2-3h | | -| 17 | Wire real product data to shop pages | — | 2-3h | | | 18 | Shipping costs at checkout | 17 | 2-3h | | | | **CSS migration (after admin stable)** | | | | | 19 | Admin design tokens (`admin-tokens.css`) | 12 | 30m | | @@ -89,9 +89,9 @@ Issues from hands-on testing of the deployed prod site (Feb 2025). 15 of 18 comp ### Tier 4 — Growth & content -13. **Page editor** — Database-driven pages with drag-and-drop sections. Extend the theme system to custom pages beyond the defaults. Replaces the static content pages from Tier 1 with editable versions. +13. **Page editor** — Database-driven pages with drag-and-drop sections. Extend the theme system to custom pages beyond the defaults. Replaces the static content pages from Tier 1 with editable versions. **Removes PreviewData usage from `content.ex`** (about, delivery, privacy, terms content blocks are currently hardcoded in PreviewData). 14. **Newsletter & email marketing** — Email list collection (signup forms). Campaign sending for product launches, sales. Can be simple initially (collect emails, send via Swoosh) or integrate with a service. -15. **Product page improvements** — Pre-checkout variant validation (verify Printify availability). Cost change monitoring/alerts. Better image gallery (zoom, multiple angles). +15. **Product page improvements** — Pre-checkout variant validation (verify Printify availability). Cost change monitoring/alerts. Better image gallery (zoom, multiple angles). **Product reviews system** to replace the hardcoded `PreviewData.reviews()` on the PDP template. ### Tier 5 — Platform vision @@ -282,6 +282,24 @@ All shop pages now have LiveView integration tests (612 total): - Oban job duration/count and LiveView mount/event telemetry metrics - os_mon for CPU, disk, and OS memory in LiveDashboard +### Products Refactor & Search +**Status:** Complete + +- [x] Denormalized product fields (cheapest_price, compare_at_price, in_stock, on_sale) recomputed from variants after sync +- [x] Product display helpers (primary_image, hover_image, option_types) and ProductImage helpers (display_url, direct_url, source_width) +- [x] Products context storefront queries (list_visible_products, get_visible_product, list_categories) with DB-level sort/filter +- [x] Renamed .name → .title across all shop components and templates +- [x] PreviewData updated to struct-compatible format (removed product_to_map indirection) +- [x] FTS5 full-text search index with BM25 ranking (title 10x, category 5x, variants 3x, description 1x) +- [x] SearchHook on_mount for all shop pages (follows CartHook pattern) +- [x] Search modal with live results (thumbnails, titles, categories, prices, click-to-navigate) +- [x] Index auto-rebuilds after each provider sync +- [x] 18 search tests, 744 total + +**Follow-ups:** +- [x] Wire shop LiveViews to direct DB queries (PreviewData removed from all shop pages, cart, error page) +- [ ] Keyboard navigation in search modal (up/down arrows, Enter to navigate) + ### Page Editor **Status:** Future (Tier 4) @@ -295,6 +313,7 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design | Feature | Commit | Notes | |---------|--------|-------| +| FTS5 search + products refactor | 037cd16 | FTS5 index, BM25 ranking, search modal, denormalized fields, Product struct usage, 744 tests | | PageSpeed CI | 516d0d0 | `mix lighthouse` task, prod asset build, gzip, 99-100 mobile scores | | Observability | eaa4bbb | LiveDashboard in prod, ErrorTracker, JSON logging, Oban/LV metrics, os_mon | | Hosting & deployment | — | Alpine Docker, Fly.io, health check, release path fixes | diff --git a/assets/js/app.js b/assets/js/app.js index 9bfd27c..8744a43 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -324,10 +324,123 @@ const ProductImageScroll = { } } +// Hook for search modal keyboard navigation and shortcuts +const SearchModal = { + mounted() { + this.selectedIndex = -1 + + this._globalKeydown = (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault() + if (this.isOpen()) { + this.close() + } else { + this.open() + } + } + } + document.addEventListener("keydown", this._globalKeydown) + + this.el.addEventListener("keydown", (e) => { + if (!this.isOpen()) return + + switch (e.key) { + case "Escape": + e.preventDefault() + this.close() + break + case "ArrowDown": + e.preventDefault() + this.moveSelection(1) + break + case "ArrowUp": + e.preventDefault() + this.moveSelection(-1) + break + case "Enter": + e.preventDefault() + this.navigateToSelected() + break + } + }) + }, + + destroyed() { + document.removeEventListener("keydown", this._globalKeydown) + }, + + updated() { + this.selectedIndex = -1 + this.updateHighlight() + }, + + isOpen() { + return this.el.style.display !== "none" && this.el.style.display !== "" + }, + + open() { + this.el.style.display = "flex" + const input = this.el.querySelector("#search-input") + if (input) { + input.focus() + input.select() + } + this.selectedIndex = -1 + this.updateHighlight() + }, + + close() { + this.el.style.display = "none" + this.pushEvent("clear_search", {}) + this.selectedIndex = -1 + this.updateHighlight() + }, + + getResults() { + return this.el.querySelectorAll('[role="option"]') + }, + + moveSelection(delta) { + const results = this.getResults() + if (results.length === 0) return + + this.selectedIndex += delta + if (this.selectedIndex < -1) this.selectedIndex = results.length - 1 + if (this.selectedIndex >= results.length) this.selectedIndex = 0 + + this.updateHighlight() + }, + + updateHighlight() { + const results = this.getResults() + results.forEach((r, i) => { + const isSelected = i === this.selectedIndex + r.style.background = isSelected ? "var(--t-surface-sunken)" : "" + r.setAttribute("aria-selected", isSelected) + }) + + if (this.selectedIndex >= 0 && results[this.selectedIndex]) { + results[this.selectedIndex].scrollIntoView({ block: "nearest" }) + } + }, + + navigateToSelected() { + const results = this.getResults() + const index = this.selectedIndex >= 0 ? this.selectedIndex : 0 + if (results[index]) { + const link = results[index].querySelector("a") + if (link) { + this.el.style.display = "none" + link.click() + } + } + } +} + const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { params: {_csrf_token: csrfToken}, - hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll}, + hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal}, }) // Show progress bar on live navigation and form submits diff --git a/lib/simpleshop_theme/cart.ex b/lib/simpleshop_theme/cart.ex index 4f0c3e7..a288520 100644 --- a/lib/simpleshop_theme/cart.ex +++ b/lib/simpleshop_theme/cart.ex @@ -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 # ============================================================================= diff --git a/lib/simpleshop_theme/products/product.ex b/lib/simpleshop_theme/products/product.ex index 005ac1f..6b1242e 100644 --- a/lib/simpleshop_theme/products/product.ex +++ b/lib/simpleshop_theme/products/product.ex @@ -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. """ diff --git a/lib/simpleshop_theme/search.ex b/lib/simpleshop_theme/search.ex index a5ca60c..02b5ac8 100644 --- a/lib/simpleshop_theme/search.ex +++ b/lib/simpleshop_theme/search.ex @@ -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] diff --git a/lib/simpleshop_theme_web/components/page_templates/collection.html.heex b/lib/simpleshop_theme_web/components/page_templates/collection.html.heex index 2f3aa2e..93bec5a 100644 --- a/lib/simpleshop_theme_web/components/page_templates/collection.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/collection.html.heex @@ -13,13 +13,13 @@ search_results={assigns[:search_results] || []} >
- <.collection_header title="All Products" product_count={length(@preview_data.products)} /> + <.collection_header title="All Products" product_count={length(assigns[:products] || [])} />
- <.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} diff --git a/lib/simpleshop_theme_web/components/page_templates/error.html.heex b/lib/simpleshop_theme_web/components/page_templates/error.html.heex index 652aa9b..f4c4612 100644 --- a/lib/simpleshop_theme_web/components/page_templates/error.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/error.html.heex @@ -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} diff --git a/lib/simpleshop_theme_web/components/page_templates/home.html.heex b/lib/simpleshop_theme_web/components/page_templates/home.html.heex index 1207243..b5dc3a0 100644 --- a/lib/simpleshop_theme_web/components/page_templates/home.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/home.html.heex @@ -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} /> diff --git a/lib/simpleshop_theme_web/components/shop_components/layout.ex b/lib/simpleshop_theme_web/components/shop_components/layout.ex index d006b4b..2c300b6 100644 --- a/lib/simpleshop_theme_web/components/shop_components/layout.ex +++ b/lib/simpleshop_theme_web/components/shop_components/layout.ex @@ -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
Phoenix.LiveView.JS.push("clear_search") - } + onclick="event.stopPropagation()" >
+