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

@ -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 |

View File

@ -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

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]

View File

@ -13,13 +13,13 @@
search_results={assigns[:search_results] || []}
>
<main id="main-content">
<.collection_header title="All Products" product_count={length(@preview_data.products)} />
<.collection_header title="All Products" product_count={length(assigns[:products] || [])} />
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<.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}

View File

@ -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}

View File

@ -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}
/>

View File

@ -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
<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")
|> Phoenix.LiveView.JS.push("clear_search")
}
onclick="event.stopPropagation()"
>
<div
class="flex items-center gap-3 p-4"
@ -391,10 +395,20 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
placeholder="Search products..."
value={@search_query}
phx-keyup="search"
phx-debounce="300"
phx-debounce="150"
autocomplete="off"
phx-click={Phoenix.LiveView.JS.dispatch("stop-propagation")}
role="combobox"
aria-expanded={to_string(@search_results != [])}
aria-controls="search-results-list"
aria-autocomplete="list"
/>
<div
class="hidden sm:flex items-center gap-1 text-xs px-1.5 py-0.5 rounded"
style="color: var(--t-text-tertiary); border: 1px solid var(--t-border-default);"
aria-hidden="true"
>
<kbd></kbd><kbd>K</kbd>
</div>
<button
type="button"
class="w-8 h-8 flex items-center justify-center transition-all"
@ -423,18 +437,20 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout 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}"}
<ul id="search-results-list" class="py-2" role="listbox" aria-label="Search results">
<li
:for={item <- @results_with_images}
id={"search-result-#{item.idx}"}
role="option"
aria-selected="false"
>
<.link
navigate={"/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")
}
phx-click={Phoenix.LiveView.JS.hide(to: "#search-modal")}
>
<div
:if={item.image_url}
@ -459,7 +475,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
</span>
</p>
</div>
</a>
</.link>
</li>
</ul>
<% String.length(@search_query) >= 2 -> %>
@ -494,12 +510,10 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
"""
attr :theme_settings, :map, required: true
attr :mode, :atom, default: :live
attr :categories, :list, default: []
def shop_footer(assigns) do
assigns =
assigns
|> assign(:current_year, Date.utc_today().year)
|> assign(:categories, SimpleshopTheme.Theme.PreviewData.categories())
assigns = assign(assigns, :current_year, Date.utc_today().year)
~H"""
<footer style="background-color: var(--t-surface-raised); border-top: 1px solid var(--t-border-default);">

View File

@ -696,7 +696,12 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
>
<div
class="w-24 h-24 rounded-full bg-gray-200 bg-cover bg-center transition-transform hover:scale-105"
style={"background-image: url('#{category.image_url}');"}
style={
if(category[:image_url],
do: "background-image: url('#{category.image_url}');",
else: ""
)
}
>
</div>
<span
@ -714,7 +719,12 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
>
<div
class="w-24 h-24 rounded-full bg-gray-200 bg-cover bg-center transition-transform hover:scale-105"
style={"background-image: url('#{category.image_url}');"}
style={
if(category[:image_url],
do: "background-image: url('#{category.image_url}');",
else: ""
)
}
>
</div>
<span

View File

@ -9,7 +9,8 @@ defmodule SimpleshopThemeWeb.ErrorHTML do
alias SimpleshopTheme.Settings
alias SimpleshopTheme.Settings.ThemeSettings
alias SimpleshopTheme.Media
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator, PreviewData}
alias SimpleshopTheme.Products
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator}
def render("404.html", assigns) do
render_error_page(
@ -39,10 +40,8 @@ defmodule SimpleshopThemeWeb.ErrorHTML do
logo_image = safe_load(&Media.get_logo/0)
header_image = safe_load(&Media.get_header/0)
preview_data = %{
products: PreviewData.products(),
categories: PreviewData.categories()
}
products = safe_load(fn -> Products.list_visible_products(limit: 4) end) || []
categories = safe_load(fn -> Products.list_categories() end) || []
assigns =
assigns
@ -50,7 +49,8 @@ defmodule SimpleshopThemeWeb.ErrorHTML do
|> Map.put(:generated_css, generated_css)
|> Map.put(:logo_image, logo_image)
|> Map.put(:header_image, header_image)
|> Map.put(:preview_data, preview_data)
|> Map.put(:products, products)
|> Map.put(:categories, categories)
|> Map.put(:error_code, error_code)
|> Map.put(:error_title, error_title)
|> Map.put(:error_description, error_description)
@ -88,7 +88,8 @@ defmodule SimpleshopThemeWeb.ErrorHTML do
theme_settings={@theme_settings}
logo_image={@logo_image}
header_image={@header_image}
preview_data={@preview_data}
products={@products}
categories={@categories}
error_code={@error_code}
error_title={@error_title}
error_description={@error_description}

View File

@ -311,6 +311,8 @@ defmodule SimpleshopThemeWeb.Admin.Theme.Index do
defp preview_assigns(assigns) do
assign(assigns, %{
mode: :preview,
products: assigns.preview_data.products,
categories: assigns.preview_data.categories,
cart_items: PreviewData.cart_drawer_items(),
cart_count: 2,
cart_subtotal: "£72.00"

View File

@ -1,7 +1,7 @@
defmodule SimpleshopThemeWeb.Shop.Collection do
use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Theme.PreviewData
alias SimpleshopTheme.Products
@sort_options [
{"featured", "Featured"},
@ -16,7 +16,6 @@ defmodule SimpleshopThemeWeb.Shop.Collection do
def mount(_params, _session, socket) do
socket =
socket
|> assign(:categories, PreviewData.categories())
|> assign(:sort_options, @sort_options)
|> assign(:current_sort, "featured")
@ -27,7 +26,7 @@ defmodule SimpleshopThemeWeb.Shop.Collection do
def handle_params(%{"slug" => slug} = params, _uri, socket) do
sort = params["sort"] || "featured"
case load_collection(slug) do
case load_collection(slug, sort) do
{:ok, title, category, products} ->
{:noreply,
socket
@ -35,7 +34,7 @@ defmodule SimpleshopThemeWeb.Shop.Collection do
|> assign(:collection_title, title)
|> assign(:current_category, category)
|> assign(:current_sort, sort)
|> assign(:products, sort_products(products, sort))}
|> assign(:products, products)}
:not_found ->
{:noreply,
@ -45,19 +44,22 @@ defmodule SimpleshopThemeWeb.Shop.Collection do
end
end
defp load_collection("all") do
{:ok, "All Products", nil, PreviewData.products()}
defp load_collection("all", sort) do
{:ok, "All Products", nil, Products.list_visible_products(sort: sort)}
end
defp load_collection("sale") do
sale_products = Enum.filter(PreviewData.products(), & &1.on_sale)
{:ok, "Sale", :sale, sale_products}
defp load_collection("sale", sort) do
{:ok, "Sale", :sale, Products.list_visible_products(on_sale: true, sort: sort)}
end
defp load_collection(slug) do
case PreviewData.category_by_slug(slug) do
nil -> :not_found
category -> {:ok, category.name, category, PreviewData.products_by_category(slug)}
defp load_collection(slug, sort) do
case Enum.find(Products.list_categories(), &(&1.slug == slug)) do
nil ->
:not_found
category ->
products = Products.list_visible_products(category: category.name, sort: sort)
{:ok, category.name, category, products}
end
end
@ -73,17 +75,6 @@ defmodule SimpleshopThemeWeb.Shop.Collection do
{:noreply, push_patch(socket, to: ~p"/collections/#{slug}?sort=#{sort}")}
end
defp sort_products(products, "featured"), do: products
defp sort_products(products, "newest"), do: Enum.reverse(products)
defp sort_products(products, "price_asc"), do: Enum.sort_by(products, & &1.cheapest_price)
defp sort_products(products, "price_desc"),
do: Enum.sort_by(products, & &1.cheapest_price, :desc)
defp sort_products(products, "name_asc"), do: Enum.sort_by(products, & &1.title)
defp sort_products(products, "name_desc"), do: Enum.sort_by(products, & &1.title, :desc)
defp sort_products(products, _), do: products
defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}"
defp collection_path(slug, sort), do: ~p"/collections/#{slug}?sort=#{sort}"

View File

@ -1,19 +1,16 @@
defmodule SimpleshopThemeWeb.Shop.Home do
use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Theme.PreviewData
alias SimpleshopTheme.Products
@impl true
def mount(_params, _session, socket) do
preview_data = %{
products: PreviewData.products(),
categories: PreviewData.categories()
}
products = Products.list_visible_products(limit: 8)
socket =
socket
|> assign(:page_title, "Home")
|> assign(:preview_data, preview_data)
|> assign(:products, products)
{:ok, socket}
end

View File

@ -2,61 +2,53 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Cart
alias SimpleshopTheme.Products
alias SimpleshopTheme.Products.{Product, ProductImage}
alias SimpleshopTheme.Theme.PreviewData
@impl true
def mount(%{"id" => id}, _session, socket) do
products = PreviewData.products()
def mount(%{"id" => slug}, _session, socket) do
case Products.get_visible_product(slug) do
nil ->
{:ok, push_navigate(socket, to: ~p"/collections/all")}
# Find product by slug or ID (real products use slugs, mock data uses string IDs)
product = find_product(products, id)
product ->
related_products =
Products.list_visible_products(
category: product.category,
limit: 4,
exclude: product.id
)
# Get related products (exclude current product, take 4)
related_products =
products
|> Enum.reject(fn p -> p.id == product.id end)
|> Enum.take(4)
gallery_images =
(product.images || [])
|> Enum.sort_by(& &1.position)
|> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
|> Enum.reject(&is_nil/1)
# Build gallery images from local image_id or external URL
gallery_images =
(Map.get(product, :images) || [])
|> Enum.sort_by(& &1.position)
|> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
|> Enum.reject(&is_nil/1)
option_types = Product.option_types(product)
variants = product.variants || []
{selected_options, selected_variant} = initialize_variant_selection(variants)
available_options = compute_available_options(option_types, variants, selected_options)
display_price = variant_price(selected_variant, product)
# Initialize variant selection
option_types = Product.option_types(product)
variants = Map.get(product, :variants) || []
{selected_options, selected_variant} = initialize_variant_selection(variants)
available_options = compute_available_options(option_types, variants, selected_options)
display_price = variant_price(selected_variant, product)
socket =
socket
|> assign(:page_title, product.title)
|> assign(:product, product)
|> assign(:gallery_images, gallery_images)
|> assign(:related_products, related_products)
|> assign(:quantity, 1)
|> assign(:option_types, option_types)
|> assign(:variants, variants)
|> assign(:selected_options, selected_options)
|> assign(:selected_variant, selected_variant)
|> assign(:available_options, available_options)
|> assign(:display_price, display_price)
socket =
socket
|> assign(:page_title, product.title)
|> assign(:product, product)
|> assign(:gallery_images, gallery_images)
|> assign(:related_products, related_products)
|> assign(:quantity, 1)
|> assign(:option_types, option_types)
|> assign(:variants, variants)
|> assign(:selected_options, selected_options)
|> assign(:selected_variant, selected_variant)
|> assign(:available_options, available_options)
|> assign(:display_price, display_price)
{:ok, socket}
{:ok, socket}
end
end
# Find product by slug first (real products), then try ID match (mock data)
defp find_product(products, id) do
Enum.find(products, fn p -> p[:slug] == id end) ||
Enum.find(products, fn p -> p.id == id end) ||
List.first(products)
end
# Select first available variant by default
defp initialize_variant_selection([first | _] = _variants) do
{first.options, first}
end
@ -65,11 +57,8 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
{%{}, nil}
end
# Compute which option values are available given current selection
defp compute_available_options(option_types, variants, selected_options) do
Enum.reduce(option_types, %{}, fn opt_type, acc ->
# For each option type, find which values have at least one available variant
# when combined with the other selected options
other_options = Map.delete(selected_options, opt_type.name)
available_values =
@ -99,10 +88,8 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
def handle_event("select_option", %{"option" => option_name, "value" => value}, socket) do
selected_options = Map.put(socket.assigns.selected_options, option_name, value)
# Find matching variant
selected_variant = find_variant(socket.assigns.variants, selected_options)
# Recompute available options based on new selection
available_options =
compute_available_options(
socket.assigns.option_types,

View File

@ -14,7 +14,7 @@ defmodule SimpleshopThemeWeb.ThemeHook do
import Phoenix.Component, only: [assign: 3]
alias SimpleshopTheme.{Settings, Media}
alias SimpleshopTheme.{Products, Settings, Media}
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator}
def on_mount(:mount_theme, _params, _session, socket) do
@ -37,6 +37,7 @@ defmodule SimpleshopThemeWeb.ThemeHook do
|> assign(:generated_css, generated_css)
|> assign(:logo_image, Media.get_logo())
|> assign(:header_image, Media.get_header())
|> assign(:categories, Products.list_categories())
|> assign(:mode, :shop)
{:cont, socket}

View File

@ -288,15 +288,27 @@ defmodule SimpleshopTheme.Products.ProductTest do
product = %{
provider_data: %{
"options" => [
%{"name" => "Size", "values" => [%{"title" => "S"}, %{"title" => "M"}]},
%{"name" => "Color", "values" => [%{"title" => "Red"}]}
%{
"name" => "Size",
"type" => "size",
"values" => [%{"title" => "S"}, %{"title" => "M"}]
},
%{
"name" => "Color",
"type" => "color",
"values" => [%{"title" => "Red", "colors" => ["#FF0000"]}]
}
]
}
}
types = Product.option_types(product)
assert length(types) == 2
assert hd(types) == %{name: "Size", values: ["S", "M"]}
assert hd(types) == %{name: "Size", type: :size, values: [%{title: "S"}, %{title: "M"}]}
color_type = Enum.at(types, 1)
assert color_type.type == :color
assert hd(color_type.values) == %{title: "Red", hex: "#FF0000"}
end
test "returns empty list when no provider_data" do

View File

@ -196,4 +196,18 @@ defmodule SimpleshopTheme.SearchTest do
assert Search.search("alpine") != []
end
end
describe "remove_product/1" do
test "removes a product from the index", %{ocean: ocean} do
assert Search.search("ocean") != []
Search.remove_product(ocean.id)
assert Search.search("ocean") == []
end
test "is a no-op for unindexed product" do
assert Search.remove_product(-1) == :ok
end
end
end

View File

@ -3,13 +3,44 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
import Phoenix.LiveViewTest
import SimpleshopTheme.AccountsFixtures
import SimpleshopTheme.ProductsFixtures
alias SimpleshopTheme.Theme.PreviewData
alias SimpleshopTheme.Products
setup do
user_fixture()
{:ok, _} = SimpleshopTheme.Settings.set_site_live(true)
:ok
pc = provider_connection_fixture()
print =
product_fixture(%{
provider_connection: pc,
title: "Mountain Sunrise Print",
category: "Art Prints"
})
product_variant_fixture(%{product: print, title: "8x10", price: 1999})
Products.recompute_cached_fields(print)
shirt =
product_fixture(%{
provider_connection: pc,
title: "Forest T-Shirt",
category: "Apparel",
on_sale: true
})
product_variant_fixture(%{
product: shirt,
title: "Large",
price: 2999,
compare_at_price: 3999
})
Products.recompute_cached_fields(shirt)
%{print: print, shirt: shirt}
end
describe "Collection page" do
@ -20,29 +51,22 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
end
test "renders collection page for specific category", %{conn: conn} do
category = List.first(PreviewData.categories())
{:ok, _view, html} = live(conn, ~p"/collections/#{category.slug}")
{:ok, _view, html} = live(conn, ~p"/collections/art-prints")
assert html =~ category.name
assert html =~ "Art Prints"
end
test "displays products", %{conn: conn} do
test "displays products", %{conn: conn, print: print} do
{:ok, _view, html} = live(conn, ~p"/collections/all")
products = PreviewData.products()
first_product = List.first(products)
assert html =~ first_product.title
assert html =~ print.title
end
test "displays category filter buttons", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/collections/all")
categories = PreviewData.categories()
for category <- categories do
assert html =~ category.name
end
assert html =~ "Art Prints"
assert html =~ "Apparel"
end
test "displays sort dropdown", %{conn: conn} do
@ -64,15 +88,11 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
assert flash["error"] == "Collection not found"
end
test "filters products by category", %{conn: conn} do
category = List.first(PreviewData.categories())
{:ok, _view, html} = live(conn, ~p"/collections/#{category.slug}")
test "filters products by category", %{conn: conn, print: print, shirt: shirt} do
{:ok, _view, html} = live(conn, ~p"/collections/art-prints")
products = PreviewData.products_by_category(category.slug)
for product <- products do
assert html =~ product.title
end
assert html =~ print.title
refute html =~ shirt.title
end
end
@ -140,8 +160,7 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
test "category links preserve sort order", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/collections/all?sort=price_desc")
category = List.first(PreviewData.categories())
assert html =~ "/collections/#{category.slug}?sort=price_desc"
assert html =~ "/collections/art-prints?sort=price_desc"
end
test "All button link preserves sort order", %{conn: conn} do
@ -153,9 +172,8 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
test "featured sort does not include query param in links", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/collections/all")
category = List.first(PreviewData.categories())
assert html =~ ~s(href="/collections/#{category.slug}")
refute html =~ "/collections/#{category.slug}?sort=featured"
assert html =~ ~s(href="/collections/art-prints")
refute html =~ "/collections/art-prints?sort=featured"
end
end
end

View File

@ -3,13 +3,27 @@ defmodule SimpleshopThemeWeb.Shop.HomeTest do
import Phoenix.LiveViewTest
import SimpleshopTheme.AccountsFixtures
alias SimpleshopTheme.Theme.PreviewData
import SimpleshopTheme.ProductsFixtures
setup do
user_fixture()
{:ok, _} = SimpleshopTheme.Settings.set_site_live(true)
:ok
conn = provider_connection_fixture()
product =
product_fixture(%{
provider_connection: conn,
title: "Mountain Sunrise Print",
category: "Art Prints"
})
product_variant_fixture(%{product: product, title: "8x10", price: 1999})
# Recompute so cheapest_price is set
SimpleshopTheme.Products.recompute_cached_fields(product)
%{product: product}
end
describe "Home page" do
@ -25,25 +39,17 @@ defmodule SimpleshopThemeWeb.Shop.HomeTest do
assert html =~ "Shop the collection"
end
test "renders category navigation", %{conn: conn} do
test "renders category navigation with real categories", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/")
categories = PreviewData.categories()
for category <- Enum.take(categories, 3) do
assert html =~ category.name
end
assert html =~ "Art Prints"
end
test "renders featured products section", %{conn: conn} do
test "renders featured products section", %{conn: conn, product: product} do
{:ok, _view, html} = live(conn, ~p"/")
assert html =~ "Featured products"
products = PreviewData.products()
first_product = List.first(products)
assert html =~ first_product.title
assert html =~ product.title
end
test "renders image and text section", %{conn: conn} do
@ -56,8 +62,7 @@ defmodule SimpleshopThemeWeb.Shop.HomeTest do
test "renders header with shop name", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/")
# Header should be present (part of shop_layout)
assert html =~ "SimpleShop"
assert html =~ "Store Name"
end
test "renders footer with links", %{conn: conn} do

View File

@ -3,142 +3,273 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
import Phoenix.LiveViewTest
import SimpleshopTheme.AccountsFixtures
import SimpleshopTheme.ProductsFixtures
alias SimpleshopTheme.Theme.PreviewData
alias SimpleshopTheme.Products
setup do
user_fixture()
{:ok, _} = SimpleshopTheme.Settings.set_site_live(true)
:ok
pc = provider_connection_fixture()
# Art print with size options and 2 images (for gallery tests)
print =
product_fixture(%{
provider_connection: pc,
title: "Mountain Sunrise Print",
category: "Art Prints",
description: "A beautiful mountain sunrise art print",
provider_data: %{
"options" => [
%{
"name" => "Size",
"type" => "size",
"values" => [
%{"title" => "8x10"},
%{"title" => "12x18"},
%{"title" => "18x24"}
]
}
]
}
})
product_variant_fixture(%{
product: print,
title: "8x10",
price: 1999,
options: %{"Size" => "8x10"}
})
product_variant_fixture(%{
product: print,
title: "12x18",
price: 2400,
options: %{"Size" => "12x18"}
})
product_variant_fixture(%{
product: print,
title: "18x24",
price: 3200,
options: %{"Size" => "18x24"}
})
product_image_fixture(%{
product: print,
position: 0,
src: "https://example.com/print-1.jpg"
})
product_image_fixture(%{
product: print,
position: 1,
src: "https://example.com/print-2.jpg"
})
Products.recompute_cached_fields(print)
# T-shirt with colour + size options (some sizes unavailable in white)
shirt =
product_fixture(%{
provider_connection: pc,
title: "Forest T-Shirt",
category: "Apparel",
description: "A forest themed t-shirt",
provider_data: %{
"options" => [
%{
"name" => "Color",
"type" => "color",
"values" => [
%{"title" => "Black", "colors" => ["#000000"]},
%{"title" => "White", "colors" => ["#FFFFFF"]}
]
},
%{
"name" => "Size",
"type" => "size",
"values" => [
%{"title" => "M"},
%{"title" => "L"},
%{"title" => "XL"}
]
}
]
}
})
for color <- ["Black", "White"], size <- ["M", "L"] do
product_variant_fixture(%{
product: shirt,
title: "#{color} / #{size}",
price: 2999,
options: %{"Color" => color, "Size" => size}
})
end
# Black / XL available
product_variant_fixture(%{
product: shirt,
title: "Black / XL",
price: 2999,
options: %{"Color" => "Black", "Size" => "XL"}
})
# White / XL unavailable
product_variant_fixture(%{
product: shirt,
title: "White / XL",
price: 2999,
options: %{"Color" => "White", "Size" => "XL"},
is_available: false
})
product_image_fixture(%{
product: shirt,
position: 0,
src: "https://example.com/shirt-1.jpg"
})
Products.recompute_cached_fields(shirt)
# Another art print for related products
related =
product_fixture(%{
provider_connection: pc,
title: "Ocean Waves Print",
category: "Art Prints"
})
product_variant_fixture(%{
product: related,
title: "12x18",
price: 2400,
options: %{"Size" => "12x18"}
})
Products.recompute_cached_fields(related)
%{print: print, shirt: shirt, related: related}
end
describe "Product detail page" do
test "renders product page with product name", %{conn: conn} do
product = List.first(PreviewData.products())
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
test "renders product page with product name", %{conn: conn, print: print} do
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
assert html =~ product.title
assert html =~ print.title
end
test "renders product description", %{conn: conn} do
product = List.first(PreviewData.products())
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
test "renders product description", %{conn: conn, print: print} do
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
assert html =~ product.description
assert html =~ print.description
end
test "renders product price", %{conn: conn} do
product = List.first(PreviewData.products())
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
test "renders product price", %{conn: conn, print: print} do
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
assert html =~ SimpleshopTheme.Cart.format_price(product.cheapest_price)
# Cheapest variant is 8x10 at £19.99
assert html =~ "£19.99"
end
test "renders breadcrumb with category link", %{conn: conn} do
product = List.first(PreviewData.products())
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
test "renders breadcrumb with category link", %{conn: conn, print: print} do
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
assert html =~ product.category
assert html =~ "Art Prints"
assert html =~ "/collections/"
end
test "renders add to cart button", %{conn: conn} do
product = List.first(PreviewData.products())
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
test "renders add to cart button", %{conn: conn, print: print} do
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
assert html =~ "Add to basket"
end
test "renders related products section", %{conn: conn} do
product = List.first(PreviewData.products())
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
test "renders related products section", %{conn: conn, print: print, related: related} do
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
# Should show other products, not the current one
other_product = Enum.at(PreviewData.products(), 1)
assert html =~ other_product.title
assert html =~ related.title
end
end
describe "Variant selection" do
test "renders variant selectors for product with options", %{conn: conn} do
# Product "1" (Mountain Sunrise Art Print) has Size options
{:ok, _view, html} = live(conn, ~p"/products/1")
test "renders variant selectors for product with size options", %{conn: conn, print: print} do
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
assert html =~ "Size"
assert html =~ "8×10"
assert html =~ "12×18"
assert html =~ "18×24"
assert html =~ "8x10"
assert html =~ "12x18"
assert html =~ "18x24"
end
test "renders color and size selectors for apparel", %{conn: conn} do
# Product "6" (Forest Silhouette T-Shirt) has Color and Size options
{:ok, _view, html} = live(conn, ~p"/products/6")
test "renders colour and size selectors for apparel", %{conn: conn, shirt: shirt} do
{:ok, _view, html} = live(conn, ~p"/products/#{shirt.slug}")
assert html =~ "Color"
assert html =~ "Size"
end
test "selecting a size updates the price", %{conn: conn} do
# Product "1" has variants: 8×10 = £19.99, 12×18 = £24.00, 18×24 = £32.00
{:ok, view, _html} = live(conn, ~p"/products/1")
test "selecting a size updates the price", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
html =
view
|> element("button[phx-value-value='18×24']")
|> element("button[phx-value-value='18x24']")
|> render_click()
assert html =~ "£32.00"
end
test "selecting a colour updates available sizes", %{conn: conn} do
# Product "6": White / XL and White / 2XL are unavailable
{:ok, view, _html} = live(conn, ~p"/products/6")
test "selecting a colour updates available sizes", %{conn: conn, shirt: shirt} do
{:ok, view, _html} = live(conn, ~p"/products/#{shirt.slug}")
html =
view
|> element("button[aria-label='Select White']")
|> render_click()
# XL should be disabled (unavailable in White)
# XL should be disabled (unavailable in white)
assert html =~ "disabled"
end
test "shows single variant for products with one option", %{conn: conn} do
# Product "2" (Ocean Waves Art Print) has a single size variant
{:ok, _view, html} = live(conn, ~p"/products/2")
test "shows variant for single-variant product", %{conn: conn, related: related} do
{:ok, _view, html} = live(conn, ~p"/products/#{related.slug}")
assert html =~ "12×18"
# Ocean Waves Print has no provider_data options, so shows "One size"
assert html =~ "One size"
end
end
describe "Quantity selector" do
test "renders quantity selector with initial value of 1", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/products/1")
test "renders quantity selector with initial value of 1", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
assert has_element?(view, "button[phx-click='decrement_quantity']")
assert has_element?(view, "button[phx-click='increment_quantity']")
end
test "decrement button is disabled at quantity 1", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/products/1")
test "decrement button is disabled at quantity 1", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
assert has_element?(view, "button[phx-click='decrement_quantity'][disabled]")
end
test "increment increases quantity", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/products/1")
test "increment increases quantity", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
html =
view
|> element("button[phx-click='increment_quantity']")
|> render_click()
# Quantity should now be 2, decrement no longer disabled
# Quantity now 2, decrement no longer disabled
refute html =~ ~s(phx-click="decrement_quantity" disabled)
end
test "decrement decreases quantity", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/products/1")
test "decrement decreases quantity", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
# Increment twice to get to 3
view |> element("button[phx-click='increment_quantity']") |> render_click()
@ -153,8 +284,8 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
refute html =~ ~s(phx-click="decrement_quantity" disabled)
end
test "quantity resets to 1 after adding to cart", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/products/1")
test "quantity resets to 1 after adding to cart", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
# Increment to 3
view |> element("button[phx-click='increment_quantity']") |> render_click()
@ -173,66 +304,61 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
end
describe "Add to cart" do
test "add to cart opens the cart drawer", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/products/1")
test "add to cart opens the cart drawer", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
html =
view
|> element("button", "Add to basket")
|> render_click()
# Cart drawer should now be open (the aria live region gets updated)
assert html =~ "added to cart"
end
test "add to cart updates cart count", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/products/1")
test "add to cart shows product in cart drawer", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
html =
view
|> element("button", "Add to basket")
|> render_click()
# The cart drawer should show the item
assert html =~ "Mountain Sunrise Art Print"
assert html =~ "Mountain Sunrise Print"
end
end
describe "Product gallery" do
test "renders carousel with hook and accessibility attrs", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/products/1")
test "renders carousel with hook and accessibility attrs", %{conn: conn, print: print} do
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
assert html =~ ~s(phx-hook="ProductImageScroll")
assert html =~ ~s(role="region")
assert html =~ ~s(aria-label="Product images")
end
test "renders all gallery images with alt text", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/products/1")
test "renders all gallery images with alt text", %{conn: conn, print: print} do
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
product = List.first(PreviewData.products())
# Each image should have descriptive alt text
assert html =~ "#{product.title} — image 1 of"
assert html =~ "#{product.title} — image 2 of"
assert html =~ "#{print.title} — image 1 of"
assert html =~ "#{print.title} — image 2 of"
end
test "renders dot indicators for multi-image gallery", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/products/1")
test "renders dot indicators for multi-image gallery", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
assert has_element?(view, ".product-image-dots")
assert has_element?(view, ".product-image-dot")
end
test "renders thumbnail grid for multi-image gallery", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/products/1")
test "renders thumbnail grid for multi-image gallery", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
assert has_element?(view, ".pdp-gallery-thumbs")
assert has_element?(view, ".pdp-thumbnail")
end
test "renders prev/next navigation arrows", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/products/1")
test "renders prev/next navigation arrows", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
assert has_element?(view, "button.pdp-nav-prev")
assert has_element?(view, "button.pdp-nav-next")
@ -240,20 +366,20 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
assert has_element?(view, ~s(button[aria-label="Next image"]))
end
test "renders lightbox dialog", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/products/1")
test "renders lightbox dialog", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
assert has_element?(view, "dialog#pdp-lightbox")
end
test "renders lightbox click target for desktop", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/products/1")
test "renders lightbox click target for desktop", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
assert has_element?(view, ".pdp-lightbox-click")
end
test "thumbnails have correct aria-labels", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/products/1")
test "thumbnails have correct aria-labels", %{conn: conn, print: print} do
{:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
assert html =~ "View image 1 of"
assert html =~ "View image 2 of"
@ -261,31 +387,25 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
end
describe "Product gallery edge cases" do
import Phoenix.LiveViewTest
test "single image renders without carousel or dots", %{conn: conn, shirt: shirt} do
{:ok, view, _html} = live(conn, ~p"/products/#{shirt.slug}")
test "single image renders without carousel or dots", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/products/1")
# The live page always has multiple images due to padding, so test
# that the component correctly renders by checking structure exists
# (single-image case would need component-level testing)
assert has_element?(view, ".pdp-gallery-carousel")
# Shirt has only 1 image — should render single view, not carousel
assert has_element?(view, ".pdp-gallery-single")
refute has_element?(view, ".product-image-dots")
end
end
describe "Navigation" do
test "product links navigate to correct product page", %{conn: conn} do
product = Enum.at(PreviewData.products(), 1)
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
test "product links navigate to correct product page", %{conn: conn, shirt: shirt} do
{:ok, _view, html} = live(conn, ~p"/products/#{shirt.slug}")
assert html =~ product.title
assert html =~ shirt.title
end
test "falls back to first product for unknown ID", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/products/nonexistent")
first_product = List.first(PreviewData.products())
assert html =~ first_product.title
test "unknown slug redirects to collections", %{conn: conn} do
assert {:error, {:live_redirect, %{to: "/collections/all"}}} =
live(conn, ~p"/products/nonexistent")
end
end
end

View File

@ -0,0 +1,131 @@
defmodule SimpleshopThemeWeb.Shop.SearchIntegrationTest do
use SimpleshopThemeWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import SimpleshopTheme.AccountsFixtures
import SimpleshopTheme.ProductsFixtures
alias SimpleshopTheme.Search
setup do
user_fixture()
{:ok, _} = SimpleshopTheme.Settings.set_site_live(true)
pc = provider_connection_fixture()
mountain =
product_fixture(%{
provider_connection: pc,
title: "Mountain Sunrise Print",
description: "A landscape scene at dawn",
category: "Art Prints"
})
product_variant_fixture(%{product: mountain, title: "8x10", price: 1999})
SimpleshopTheme.Products.recompute_cached_fields(mountain)
ocean =
product_fixture(%{
provider_connection: pc,
title: "Ocean Waves Notebook",
description: "Spiral-bound notebook with ocean art",
category: "Stationery"
})
product_variant_fixture(%{product: ocean, title: "A5", price: 1299})
SimpleshopTheme.Products.recompute_cached_fields(ocean)
Search.rebuild_index()
%{mountain: mountain, ocean: ocean}
end
describe "search event" do
test "returns matching products", %{conn: conn, mountain: mountain} do
{:ok, view, _html} = live(conn, ~p"/")
html = render_hook(view, "search", %{"value" => "mountain"})
assert html =~ mountain.title
assert html =~ ~p"/products/#{mountain.slug}"
end
test "shows no results message for unmatched query", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/")
html = render_hook(view, "search", %{"value" => "xyznonexistent"})
assert html =~ "No products found"
end
test "ignores short queries", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/")
html = render_hook(view, "search", %{"value" => "a"})
refute html =~ "No products found"
refute html =~ ~s(role="option")
end
end
describe "clear_search event" do
test "resets search state", %{conn: conn, mountain: mountain} do
{:ok, view, _html} = live(conn, ~p"/")
# First search to get results
html = render_hook(view, "search", %{"value" => "mountain"})
assert html =~ mountain.title
# Clear search
html = render_hook(view, "clear_search", %{})
refute html =~ ~s(No products found)
# Results list should be gone
refute html =~ ~s(role="option")
end
end
describe "search results rendering" do
test "result links use navigate for LiveView navigation", %{conn: conn, mountain: mountain} do
{:ok, view, _html} = live(conn, ~p"/")
html = render_hook(view, "search", %{"value" => "mountain"})
assert html =~ ~s(href="/products/#{mountain.slug}")
assert html =~ ~s(data-phx-link="redirect")
end
test "results have ARIA listbox and option roles", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/")
html = render_hook(view, "search", %{"value" => "mountain"})
assert html =~ ~s(role="listbox")
assert html =~ ~s(role="option")
end
test "search input has combobox ARIA attributes", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/")
assert html =~ ~s(role="combobox")
assert html =~ ~s(aria-autocomplete="list")
assert html =~ ~s(aria-controls="search-results-list")
end
test "multiple results render in order", %{conn: conn, mountain: mountain, ocean: ocean} do
{:ok, view, _html} = live(conn, ~p"/")
html = render_hook(view, "search", %{"value" => "print notebook"})
assert html =~ mountain.title || html =~ ocean.title
end
test "shows category and price in results", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/")
html = render_hook(view, "search", %{"value" => "mountain"})
assert html =~ "Art Prints"
end
end
end