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 - Stripe Checkout with order persistence and webhook handling
- Admin credentials page with guided Stripe setup flow - Admin credentials page with guided Stripe setup flow
- Encrypted settings for API keys and secrets - 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) - Transactional emails (order confirmation, shipping notification)
- Demo content polished and ready for production - Demo content polished and ready for production
@ -26,7 +27,7 @@
Ordered by dependency level — admin shell chain first (unblocks most downstream work). 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 | | # | 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 | | ~~12~~ | ~~Consolidate settings page~~ | 6, 7 | 2-3h | done |
| ~~13~~ | ~~Admin dashboard (+ setup checklist)~~ | 6, 7, 9 | 2h | done | | ~~13~~ | ~~Admin dashboard (+ setup checklist)~~ | 6, 7, 9 | 2h | done |
| ~~15~~ | ~~Setup wizard + admin tests~~ | 13 | 1.5h | 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** | | | | | | **Next up** | | | |
| 5 | Search (functional search with results) | — | 3-4h | |
| | **Needs admin stable** | | | |
| 16 | Variant refinement with live data | — | 2-3h | | | 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 | | | 18 | Shipping costs at checkout | 17 | 2-3h | |
| | **CSS migration (after admin stable)** | | | | | | **CSS migration (after admin stable)** | | | |
| 19 | Admin design tokens (`admin-tokens.css`) | 12 | 30m | | | 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 ### 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. 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 ### 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 - Oban job duration/count and LiveView mount/event telemetry metrics
- os_mon for CPU, disk, and OS memory in LiveDashboard - 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 ### Page Editor
**Status:** Future (Tier 4) **Status:** Future (Tier 4)
@ -295,6 +313,7 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design
| Feature | Commit | Notes | | 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 | | 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 | | Observability | eaa4bbb | LiveDashboard in prod, ErrorTracker, JSON logging, Oban/LV metrics, os_mon |
| Hosting & deployment | — | Alpine Docker, Fly.io, health check, release path fixes | | 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 csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, { const liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken}, 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 // 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
alias SimpleshopTheme.Products.{Product, ProductImage}
@session_key "cart" @session_key "cart"
@ -118,18 +117,11 @@ defmodule SimpleshopTheme.Cart do
else else
variants_map = Products.get_variants_with_products(variant_ids) 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 cart_items
|> Enum.map(fn {variant_id, quantity} -> |> Enum.map(fn {variant_id, quantity} ->
case Map.get(variants_map, variant_id) do case Map.get(variants_map, variant_id) do
nil -> nil ->
case Map.get(mock_map, variant_id) do nil
nil -> nil
item -> %{item | quantity: quantity}
end
variant -> variant ->
%{ %{
@ -170,33 +162,6 @@ defmodule SimpleshopTheme.Cart do
end end
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 # Helpers
# ============================================================================= # =============================================================================

View File

@ -102,20 +102,33 @@ defmodule SimpleshopTheme.Products.Product do
@doc """ @doc """
Extracts option types from provider_data. 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 def option_types(%{provider_data: %{"options" => options}}) when is_list(options) do
Enum.map(options, fn opt -> Enum.map(options, fn opt ->
%{ type = option_type_atom(opt["type"])
name: opt["name"],
values: Enum.map(opt["values"] || [], & &1["title"]) 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)
end end
def option_types(%{option_types: option_types}) when is_list(option_types), do: option_types def option_types(%{option_types: option_types}) when is_list(option_types), do: option_types
def option_types(_), do: [] def option_types(_), do: []
defp option_type_atom("color"), do: :color
defp option_type_atom(_), do: :size
@doc """ @doc """
Generates a checksum from provider data for detecting changes. 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 def index_product(%Product{} = product) do
product = Repo.preload(product, [:variants], force: true) 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 end
# Build an FTS5 MATCH query from user input. # Build an FTS5 MATCH query from user input.
@ -126,20 +136,13 @@ defmodule SimpleshopTheme.Search do
end end
defp insert_into_index(%Product{} = product) do defp insert_into_index(%Product{} = product) do
# Insert mapping row
Repo.query!( Repo.query!(
"INSERT INTO products_search_map (product_id) VALUES (?1)", "INSERT INTO products_search_map (product_id) VALUES (?1)",
[product.id] [product.id]
) )
# Get the rowid just inserted %{rows: [[rowid]]} = Repo.query!("SELECT last_insert_rowid()")
%{rows: [[rowid]]} =
Repo.query!(
"SELECT rowid FROM products_search_map WHERE product_id = ?1",
[product.id]
)
# Build index content
variant_info = build_variant_info(product.variants || []) variant_info = build_variant_info(product.variants || [])
description = strip_html(product.description || "") description = strip_html(product.description || "")
@ -153,7 +156,6 @@ defmodule SimpleshopTheme.Search do
end end
defp remove_from_index(product_id) do defp remove_from_index(product_id) do
# Get the rowid for this product (if indexed)
case Repo.query!( case Repo.query!(
"SELECT rowid FROM products_search_map WHERE product_id = ?1", "SELECT rowid FROM products_search_map WHERE product_id = ?1",
[product_id] [product_id]

View File

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

View File

@ -22,11 +22,11 @@
mode={@mode} mode={@mode}
/> />
<.category_nav categories={@preview_data.categories} mode={@mode} /> <.category_nav categories={assigns[:categories] || []} mode={@mode} />
<.featured_products_section <.featured_products_section
title="Featured products" title="Featured products"
products={@preview_data.products} products={assigns[:products] || []}
theme_settings={@theme_settings} theme_settings={@theme_settings}
mode={@mode} mode={@mode}
/> />

View File

@ -97,7 +97,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
{render_slot(@inner_block)} {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_drawer
cart_items={@cart_items} cart_items={@cart_items}
@ -341,9 +345,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
assign( assign(
assigns, assigns,
:results_with_images, :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) 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) end)
) )
@ -352,6 +358,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
id="search-modal" id="search-modal"
class="search-modal" class="search-modal"
style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1001; display: none; align-items: flex-start; justify-content: center; padding-top: 10vh;" style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1001; display: none; align-items: flex-start; justify-content: center; padding-top: 10vh;"
phx-hook="SearchModal"
phx-click={ phx-click={
Phoenix.LiveView.JS.hide(to: "#search-modal") Phoenix.LiveView.JS.hide(to: "#search-modal")
|> Phoenix.LiveView.JS.push("clear_search") |> Phoenix.LiveView.JS.push("clear_search")
@ -360,10 +367,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
<div <div
class="search-modal-content w-full max-w-xl mx-4" class="search-modal-content w-full max-w-xl mx-4"
style="background: var(--t-surface-raised); border-radius: var(--t-radius-card); overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);" style="background: var(--t-surface-raised); border-radius: var(--t-radius-card); overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);"
phx-click-away={ onclick="event.stopPropagation()"
Phoenix.LiveView.JS.hide(to: "#search-modal")
|> Phoenix.LiveView.JS.push("clear_search")
}
> >
<div <div
class="flex items-center gap-3 p-4" class="flex items-center gap-3 p-4"
@ -391,10 +395,20 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
placeholder="Search products..." placeholder="Search products..."
value={@search_query} value={@search_query}
phx-keyup="search" phx-keyup="search"
phx-debounce="300" phx-debounce="150"
autocomplete="off" 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 <button
type="button" type="button"
class="w-8 h-8 flex items-center justify-center transition-all" class="w-8 h-8 flex items-center justify-center transition-all"
@ -423,18 +437,20 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
<div class="search-results" style="max-height: 60vh; overflow-y: auto;"> <div class="search-results" style="max-height: 60vh; overflow-y: auto;">
<%= cond do %> <%= cond do %>
<% @search_results != [] -> %> <% @search_results != [] -> %>
<ul class="py-2" role="listbox" aria-label="Search results"> <ul id="search-results-list" class="py-2" role="listbox" aria-label="Search results">
<li :for={item <- @results_with_images} role="option"> <li
<a :for={item <- @results_with_images}
href={"/products/#{item.product.slug || item.product.id}"} 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" class="flex items-center gap-3 px-4 py-3 transition-colors"
style="text-decoration: none; color: inherit;" style="text-decoration: none; color: inherit;"
onmouseenter="this.style.background='var(--t-surface-sunken)'" onmouseenter="this.style.background='var(--t-surface-sunken)'"
onmouseleave="this.style.background='transparent'" onmouseleave="this.style.background='transparent'"
phx-click={ phx-click={Phoenix.LiveView.JS.hide(to: "#search-modal")}
Phoenix.LiveView.JS.hide(to: "#search-modal")
|> Phoenix.LiveView.JS.push("clear_search")
}
> >
<div <div
:if={item.image_url} :if={item.image_url}
@ -459,7 +475,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
</span> </span>
</p> </p>
</div> </div>
</a> </.link>
</li> </li>
</ul> </ul>
<% String.length(@search_query) >= 2 -> %> <% String.length(@search_query) >= 2 -> %>
@ -494,12 +510,10 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
""" """
attr :theme_settings, :map, required: true attr :theme_settings, :map, required: true
attr :mode, :atom, default: :live attr :mode, :atom, default: :live
attr :categories, :list, default: []
def shop_footer(assigns) do def shop_footer(assigns) do
assigns = assigns = assign(assigns, :current_year, Date.utc_today().year)
assigns
|> assign(:current_year, Date.utc_today().year)
|> assign(:categories, SimpleshopTheme.Theme.PreviewData.categories())
~H""" ~H"""
<footer style="background-color: var(--t-surface-raised); border-top: 1px solid var(--t-border-default);"> <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 <div
class="w-24 h-24 rounded-full bg-gray-200 bg-cover bg-center transition-transform hover:scale-105" 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> </div>
<span <span
@ -714,7 +719,12 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
> >
<div <div
class="w-24 h-24 rounded-full bg-gray-200 bg-cover bg-center transition-transform hover:scale-105" 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> </div>
<span <span

View File

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

View File

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

View File

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

View File

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

View File

@ -2,61 +2,53 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
use SimpleshopThemeWeb, :live_view use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Cart alias SimpleshopTheme.Cart
alias SimpleshopTheme.Products
alias SimpleshopTheme.Products.{Product, ProductImage} alias SimpleshopTheme.Products.{Product, ProductImage}
alias SimpleshopTheme.Theme.PreviewData
@impl true @impl true
def mount(%{"id" => id}, _session, socket) do def mount(%{"id" => slug}, _session, socket) do
products = PreviewData.products() 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 ->
product = find_product(products, id) related_products =
Products.list_visible_products(
category: product.category,
limit: 4,
exclude: product.id
)
# Get related products (exclude current product, take 4) gallery_images =
related_products = (product.images || [])
products |> Enum.sort_by(& &1.position)
|> Enum.reject(fn p -> p.id == product.id end) |> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
|> Enum.take(4) |> Enum.reject(&is_nil/1)
# Build gallery images from local image_id or external URL option_types = Product.option_types(product)
gallery_images = variants = product.variants || []
(Map.get(product, :images) || []) {selected_options, selected_variant} = initialize_variant_selection(variants)
|> Enum.sort_by(& &1.position) available_options = compute_available_options(option_types, variants, selected_options)
|> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end) display_price = variant_price(selected_variant, product)
|> Enum.reject(&is_nil/1)
# Initialize variant selection socket =
option_types = Product.option_types(product) socket
variants = Map.get(product, :variants) || [] |> assign(:page_title, product.title)
{selected_options, selected_variant} = initialize_variant_selection(variants) |> assign(:product, product)
available_options = compute_available_options(option_types, variants, selected_options) |> assign(:gallery_images, gallery_images)
display_price = variant_price(selected_variant, product) |> 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 = {:ok, socket}
socket end
|> 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}
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 defp initialize_variant_selection([first | _] = _variants) do
{first.options, first} {first.options, first}
end end
@ -65,11 +57,8 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
{%{}, nil} {%{}, nil}
end end
# Compute which option values are available given current selection
defp compute_available_options(option_types, variants, selected_options) do defp compute_available_options(option_types, variants, selected_options) do
Enum.reduce(option_types, %{}, fn opt_type, acc -> 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) other_options = Map.delete(selected_options, opt_type.name)
available_values = available_values =
@ -99,10 +88,8 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
def handle_event("select_option", %{"option" => option_name, "value" => value}, socket) do def handle_event("select_option", %{"option" => option_name, "value" => value}, socket) do
selected_options = Map.put(socket.assigns.selected_options, option_name, value) selected_options = Map.put(socket.assigns.selected_options, option_name, value)
# Find matching variant
selected_variant = find_variant(socket.assigns.variants, selected_options) selected_variant = find_variant(socket.assigns.variants, selected_options)
# Recompute available options based on new selection
available_options = available_options =
compute_available_options( compute_available_options(
socket.assigns.option_types, socket.assigns.option_types,

View File

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

View File

@ -288,15 +288,27 @@ defmodule SimpleshopTheme.Products.ProductTest do
product = %{ product = %{
provider_data: %{ provider_data: %{
"options" => [ "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) types = Product.option_types(product)
assert length(types) == 2 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 end
test "returns empty list when no provider_data" do test "returns empty list when no provider_data" do

View File

@ -196,4 +196,18 @@ defmodule SimpleshopTheme.SearchTest do
assert Search.search("alpine") != [] assert Search.search("alpine") != []
end end
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 end

View File

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

View File

@ -3,13 +3,27 @@ defmodule SimpleshopThemeWeb.Shop.HomeTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import SimpleshopTheme.AccountsFixtures import SimpleshopTheme.AccountsFixtures
import SimpleshopTheme.ProductsFixtures
alias SimpleshopTheme.Theme.PreviewData
setup do setup do
user_fixture() user_fixture()
{:ok, _} = SimpleshopTheme.Settings.set_site_live(true) {: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 end
describe "Home page" do describe "Home page" do
@ -25,25 +39,17 @@ defmodule SimpleshopThemeWeb.Shop.HomeTest do
assert html =~ "Shop the collection" assert html =~ "Shop the collection"
end end
test "renders category navigation", %{conn: conn} do test "renders category navigation with real categories", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/") {:ok, _view, html} = live(conn, ~p"/")
categories = PreviewData.categories() assert html =~ "Art Prints"
for category <- Enum.take(categories, 3) do
assert html =~ category.name
end
end 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"/") {:ok, _view, html} = live(conn, ~p"/")
assert html =~ "Featured products" assert html =~ "Featured products"
assert html =~ product.title
products = PreviewData.products()
first_product = List.first(products)
assert html =~ first_product.title
end end
test "renders image and text section", %{conn: conn} do 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 test "renders header with shop name", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/") {:ok, _view, html} = live(conn, ~p"/")
# Header should be present (part of shop_layout) assert html =~ "Store Name"
assert html =~ "SimpleShop"
end end
test "renders footer with links", %{conn: conn} do test "renders footer with links", %{conn: conn} do

View File

@ -3,142 +3,273 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import SimpleshopTheme.AccountsFixtures import SimpleshopTheme.AccountsFixtures
import SimpleshopTheme.ProductsFixtures
alias SimpleshopTheme.Theme.PreviewData alias SimpleshopTheme.Products
setup do setup do
user_fixture() user_fixture()
{:ok, _} = SimpleshopTheme.Settings.set_site_live(true) {: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 end
describe "Product detail page" do describe "Product detail page" do
test "renders product page with product name", %{conn: conn} do test "renders product page with product name", %{conn: conn, print: print} do
product = List.first(PreviewData.products()) {:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
assert html =~ product.title assert html =~ print.title
end end
test "renders product description", %{conn: conn} do test "renders product description", %{conn: conn, print: print} do
product = List.first(PreviewData.products()) {:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
assert html =~ product.description assert html =~ print.description
end end
test "renders product price", %{conn: conn} do test "renders product price", %{conn: conn, print: print} do
product = List.first(PreviewData.products()) {:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
assert html =~ SimpleshopTheme.Cart.format_price(product.cheapest_price) # Cheapest variant is 8x10 at £19.99
assert html =~ "£19.99"
end end
test "renders breadcrumb with category link", %{conn: conn} do test "renders breadcrumb with category link", %{conn: conn, print: print} do
product = List.first(PreviewData.products()) {:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
assert html =~ product.category assert html =~ "Art Prints"
assert html =~ "/collections/" assert html =~ "/collections/"
end end
test "renders add to cart button", %{conn: conn} do test "renders add to cart button", %{conn: conn, print: print} do
product = List.first(PreviewData.products()) {:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
assert html =~ "Add to basket" assert html =~ "Add to basket"
end end
test "renders related products section", %{conn: conn} do test "renders related products section", %{conn: conn, print: print, related: related} do
product = List.first(PreviewData.products()) {:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
# Should show other products, not the current one assert html =~ related.title
other_product = Enum.at(PreviewData.products(), 1)
assert html =~ other_product.title
end end
end end
describe "Variant selection" do describe "Variant selection" do
test "renders variant selectors for product with options", %{conn: conn} do test "renders variant selectors for product with size options", %{conn: conn, print: print} do
# Product "1" (Mountain Sunrise Art Print) has Size options {:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
{:ok, _view, html} = live(conn, ~p"/products/1")
assert html =~ "Size" assert html =~ "Size"
assert html =~ "8×10" assert html =~ "8x10"
assert html =~ "12×18" assert html =~ "12x18"
assert html =~ "18×24" assert html =~ "18x24"
end end
test "renders color and size selectors for apparel", %{conn: conn} do test "renders colour and size selectors for apparel", %{conn: conn, shirt: shirt} do
# Product "6" (Forest Silhouette T-Shirt) has Color and Size options {:ok, _view, html} = live(conn, ~p"/products/#{shirt.slug}")
{:ok, _view, html} = live(conn, ~p"/products/6")
assert html =~ "Color" assert html =~ "Color"
assert html =~ "Size" assert html =~ "Size"
end end
test "selecting a size updates the price", %{conn: conn} do test "selecting a size updates the price", %{conn: conn, print: print} do
# Product "1" has variants: 8×10 = £19.99, 12×18 = £24.00, 18×24 = £32.00 {:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
{:ok, view, _html} = live(conn, ~p"/products/1")
html = html =
view view
|> element("button[phx-value-value='18×24']") |> element("button[phx-value-value='18x24']")
|> render_click() |> render_click()
assert html =~ "£32.00" assert html =~ "£32.00"
end end
test "selecting a colour updates available sizes", %{conn: conn} do test "selecting a colour updates available sizes", %{conn: conn, shirt: shirt} do
# Product "6": White / XL and White / 2XL are unavailable {:ok, view, _html} = live(conn, ~p"/products/#{shirt.slug}")
{:ok, view, _html} = live(conn, ~p"/products/6")
html = html =
view view
|> element("button[aria-label='Select White']") |> element("button[aria-label='Select White']")
|> render_click() |> render_click()
# XL should be disabled (unavailable in White) # XL should be disabled (unavailable in white)
assert html =~ "disabled" assert html =~ "disabled"
end end
test "shows single variant for products with one option", %{conn: conn} do test "shows variant for single-variant product", %{conn: conn, related: related} do
# Product "2" (Ocean Waves Art Print) has a single size variant {:ok, _view, html} = live(conn, ~p"/products/#{related.slug}")
{:ok, _view, html} = live(conn, ~p"/products/2")
assert html =~ "12×18" # Ocean Waves Print has no provider_data options, so shows "One size"
assert html =~ "One size"
end end
end end
describe "Quantity selector" do describe "Quantity selector" do
test "renders quantity selector with initial value of 1", %{conn: conn} do test "renders quantity selector with initial value of 1", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/1") {: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='decrement_quantity']")
assert has_element?(view, "button[phx-click='increment_quantity']") assert has_element?(view, "button[phx-click='increment_quantity']")
end end
test "decrement button is disabled at quantity 1", %{conn: conn} do test "decrement button is disabled at quantity 1", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/1") {:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
assert has_element?(view, "button[phx-click='decrement_quantity'][disabled]") assert has_element?(view, "button[phx-click='decrement_quantity'][disabled]")
end end
test "increment increases quantity", %{conn: conn} do test "increment increases quantity", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/1") {:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
html = html =
view view
|> element("button[phx-click='increment_quantity']") |> element("button[phx-click='increment_quantity']")
|> render_click() |> 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) refute html =~ ~s(phx-click="decrement_quantity" disabled)
end end
test "decrement decreases quantity", %{conn: conn} do test "decrement decreases quantity", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/1") {:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
# Increment twice to get to 3 # Increment twice to get to 3
view |> element("button[phx-click='increment_quantity']") |> render_click() 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) refute html =~ ~s(phx-click="decrement_quantity" disabled)
end end
test "quantity resets to 1 after adding to cart", %{conn: conn} do test "quantity resets to 1 after adding to cart", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/1") {:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
# Increment to 3 # Increment to 3
view |> element("button[phx-click='increment_quantity']") |> render_click() view |> element("button[phx-click='increment_quantity']") |> render_click()
@ -173,66 +304,61 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
end end
describe "Add to cart" do describe "Add to cart" do
test "add to cart opens the cart drawer", %{conn: conn} do test "add to cart opens the cart drawer", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/1") {:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
html = html =
view view
|> element("button", "Add to basket") |> element("button", "Add to basket")
|> render_click() |> render_click()
# Cart drawer should now be open (the aria live region gets updated)
assert html =~ "added to cart" assert html =~ "added to cart"
end end
test "add to cart updates cart count", %{conn: conn} do test "add to cart shows product in cart drawer", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/1") {:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
html = html =
view view
|> element("button", "Add to basket") |> element("button", "Add to basket")
|> render_click() |> render_click()
# The cart drawer should show the item assert html =~ "Mountain Sunrise Print"
assert html =~ "Mountain Sunrise Art Print"
end end
end end
describe "Product gallery" do describe "Product gallery" do
test "renders carousel with hook and accessibility attrs", %{conn: conn} do test "renders carousel with hook and accessibility attrs", %{conn: conn, print: print} do
{:ok, _view, html} = live(conn, ~p"/products/1") {:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
assert html =~ ~s(phx-hook="ProductImageScroll") assert html =~ ~s(phx-hook="ProductImageScroll")
assert html =~ ~s(role="region") assert html =~ ~s(role="region")
assert html =~ ~s(aria-label="Product images") assert html =~ ~s(aria-label="Product images")
end end
test "renders all gallery images with alt text", %{conn: conn} do test "renders all gallery images with alt text", %{conn: conn, print: print} do
{:ok, _view, html} = live(conn, ~p"/products/1") {:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
product = List.first(PreviewData.products()) assert html =~ "#{print.title} — image 1 of"
assert html =~ "#{print.title} — image 2 of"
# Each image should have descriptive alt text
assert html =~ "#{product.title} — image 1 of"
assert html =~ "#{product.title} — image 2 of"
end end
test "renders dot indicators for multi-image gallery", %{conn: conn} do test "renders dot indicators for multi-image gallery", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/1") {:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
assert has_element?(view, ".product-image-dots") assert has_element?(view, ".product-image-dots")
assert has_element?(view, ".product-image-dot") assert has_element?(view, ".product-image-dot")
end end
test "renders thumbnail grid for multi-image gallery", %{conn: conn} do test "renders thumbnail grid for multi-image gallery", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/1") {:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
assert has_element?(view, ".pdp-gallery-thumbs") assert has_element?(view, ".pdp-gallery-thumbs")
assert has_element?(view, ".pdp-thumbnail") assert has_element?(view, ".pdp-thumbnail")
end end
test "renders prev/next navigation arrows", %{conn: conn} do test "renders prev/next navigation arrows", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/1") {:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
assert has_element?(view, "button.pdp-nav-prev") assert has_element?(view, "button.pdp-nav-prev")
assert has_element?(view, "button.pdp-nav-next") 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"])) assert has_element?(view, ~s(button[aria-label="Next image"]))
end end
test "renders lightbox dialog", %{conn: conn} do test "renders lightbox dialog", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/1") {:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
assert has_element?(view, "dialog#pdp-lightbox") assert has_element?(view, "dialog#pdp-lightbox")
end end
test "renders lightbox click target for desktop", %{conn: conn} do test "renders lightbox click target for desktop", %{conn: conn, print: print} do
{:ok, view, _html} = live(conn, ~p"/products/1") {:ok, view, _html} = live(conn, ~p"/products/#{print.slug}")
assert has_element?(view, ".pdp-lightbox-click") assert has_element?(view, ".pdp-lightbox-click")
end end
test "thumbnails have correct aria-labels", %{conn: conn} do test "thumbnails have correct aria-labels", %{conn: conn, print: print} do
{:ok, _view, html} = live(conn, ~p"/products/1") {:ok, _view, html} = live(conn, ~p"/products/#{print.slug}")
assert html =~ "View image 1 of" assert html =~ "View image 1 of"
assert html =~ "View image 2 of" assert html =~ "View image 2 of"
@ -261,31 +387,25 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
end end
describe "Product gallery edge cases" do 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 # Shirt has only 1 image — should render single view, not carousel
{:ok, view, _html} = live(conn, ~p"/products/1") assert has_element?(view, ".pdp-gallery-single")
refute has_element?(view, ".product-image-dots")
# 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")
end end
end end
describe "Navigation" do describe "Navigation" do
test "product links navigate to correct product page", %{conn: conn} do test "product links navigate to correct product page", %{conn: conn, shirt: shirt} do
product = Enum.at(PreviewData.products(), 1) {:ok, _view, html} = live(conn, ~p"/products/#{shirt.slug}")
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
assert html =~ product.title assert html =~ shirt.title
end end
test "falls back to first product for unknown ID", %{conn: conn} do test "unknown slug redirects to collections", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/products/nonexistent") assert {:error, {:live_redirect, %{to: "/collections/all"}}} =
live(conn, ~p"/products/nonexistent")
first_product = List.first(PreviewData.products())
assert html =~ first_product.title
end end
end 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