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()" >
+