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