- <.product_info product={Map.put(@product, :price, @display_price)} />
+ <.product_info product={@product} display_price={@display_price} />
<%!-- Dynamic variant selectors --%>
<%= for option_type <- @option_types do %>
diff --git a/lib/simpleshop_theme_web/components/shop_components/cart.ex b/lib/simpleshop_theme_web/components/shop_components/cart.ex
index 90cd998..e164265 100644
--- a/lib/simpleshop_theme_web/components/shop_components/cart.ex
+++ b/lib/simpleshop_theme_web/components/shop_components/cart.ex
@@ -5,6 +5,8 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
import SimpleshopThemeWeb.ShopComponents.Base
+ alias SimpleshopTheme.Products.{Product, ProductImage}
+
defp close_cart_drawer_js do
Phoenix.LiveView.JS.push("close_cart_drawer")
end
@@ -355,8 +357,8 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
style="border-radius: var(--t-radius-image);"
>
- {@item.product.name}
+ {@item.product.title}
{@item.variant}
@@ -398,13 +400,17 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
"""
end
+ defp cart_item_image(product) do
+ ProductImage.direct_url(Product.primary_image(product), 400)
+ end
+
@doc """
Renders the order summary card.
diff --git a/lib/simpleshop_theme_web/components/shop_components/product.ex b/lib/simpleshop_theme_web/components/shop_components/product.ex
index 2e58db1..f3c20a4 100644
--- a/lib/simpleshop_theme_web/components/shop_components/product.ex
+++ b/lib/simpleshop_theme_web/components/shop_components/product.ex
@@ -4,12 +4,14 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
import SimpleshopThemeWeb.ShopComponents.Base
import SimpleshopThemeWeb.ShopComponents.Content, only: [responsive_image: 1]
+ alias SimpleshopTheme.Products.{Product, ProductImage}
+
@doc """
Renders a product card with configurable variants.
## Attributes
- * `product` - Required. The product map with `name`, `image_url`, `price`, etc.
+ * `product` - Required. The product struct with `title`, `cheapest_price`, `images`, etc.
* `theme_settings` - Required. The theme settings map.
* `mode` - Either `:live` (default) or `:preview`.
* `variant` - The visual variant:
@@ -89,12 +91,15 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
attr :mode, :atom, default: :live
defp product_card_inner(assigns) do
+ product = assigns.product
+ primary_image = Product.primary_image(product)
+ hover_image = Product.hover_image(product)
+
assigns =
- assign(
- assigns,
- :has_hover_image,
- assigns.theme_settings.hover_image && assigns.product[:hover_image_url]
- )
+ assigns
+ |> assign(:primary_image, primary_image)
+ |> assign(:hover_image, hover_image)
+ |> assign(:has_hover_image, assigns.theme_settings.hover_image && hover_image != nil)
~H"""
@@ -325,15 +325,15 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
{SimpleshopTheme.Cart.format_price(@product.compare_at_price)}
<% end %>
- {SimpleshopTheme.Cart.format_price(@product.price)}
+ {SimpleshopTheme.Cart.format_price(@product.cheapest_price)}
<% :compact -> %>
- {SimpleshopTheme.Cart.format_price(@product.price)}
+ {SimpleshopTheme.Cart.format_price(@product.cheapest_price)}
- {SimpleshopTheme.Cart.format_price(@product.price)}
+ {SimpleshopTheme.Cart.format_price(@product.cheapest_price)}
<% end %>
"""
@@ -1130,7 +1130,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
## Examples
- <.product_gallery images={@product_images} product_name={@product.name} />
+ <.product_gallery images={@product_images} product_name={@product.title} />
"""
attr :images, :list, required: true
attr :product_name, :string, required: true
@@ -1401,23 +1401,27 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
## Attributes
- * `product` - Required. Product map with `name`, `price`, `on_sale`, `compare_at_price`.
- * `currency` - Optional. Currency symbol. Defaults to "£".
+ * `product` - Required. Product struct with `title`, `cheapest_price`, `on_sale`, `compare_at_price`.
+ * `display_price` - Optional. Override price to display (e.g. selected variant price).
## Examples
<.product_info product={@product} />
+ <.product_info product={@product} display_price={@display_price} />
"""
attr :product, :map, required: true
+ attr :display_price, :integer, default: nil
def product_info(assigns) do
+ assigns = assign(assigns, :price, assigns.display_price || assigns.product.cheapest_price)
+
~H"""
- {@product.name}
+ {@product.title}
@@ -1426,7 +1430,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
class="text-3xl font-bold"
style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));"
>
- {SimpleshopTheme.Cart.format_price(@product.price)}
+ {SimpleshopTheme.Cart.format_price(@price)}
{SimpleshopTheme.Cart.format_price(@product.compare_at_price)}
@@ -1435,13 +1439,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
class="px-2 py-1 text-sm font-bold text-white rounded"
style="background-color: var(--t-sale-color);"
>
- SAVE {round(
- (@product.compare_at_price - @product.price) / @product.compare_at_price * 100
- )}%
+ SAVE {round((@product.compare_at_price - @price) / @product.compare_at_price * 100)}%
<% else %>
- {SimpleshopTheme.Cart.format_price(@product.price)}
+ {SimpleshopTheme.Cart.format_price(@price)}
<% end %>
diff --git a/lib/simpleshop_theme_web/live/admin/theme/index.ex b/lib/simpleshop_theme_web/live/admin/theme/index.ex
index 579a122..128273d 100644
--- a/lib/simpleshop_theme_web/live/admin/theme/index.ex
+++ b/lib/simpleshop_theme_web/live/admin/theme/index.ex
@@ -353,7 +353,7 @@ defmodule SimpleshopThemeWeb.Admin.Theme.Index do
end)
display_price =
- if selected_variant, do: selected_variant.price, else: product.price
+ if selected_variant, do: selected_variant.price, else: product.cheapest_price
assigns =
assigns
@@ -374,7 +374,9 @@ defmodule SimpleshopThemeWeb.Admin.Theme.Index do
cart_items = assigns.preview_data.cart_items
subtotal =
- Enum.reduce(cart_items, 0, fn item, acc -> acc + item.product.price * item.quantity end)
+ Enum.reduce(cart_items, 0, fn item, acc ->
+ acc + item.product.cheapest_price * item.quantity
+ end)
assigns =
assigns
@@ -464,6 +466,15 @@ defmodule SimpleshopThemeWeb.Admin.Theme.Index do
end
defp build_gallery_images(product) do
- [product.image_url, product.hover_image_url, product.image_url, product.hover_image_url]
+ alias SimpleshopTheme.Products.ProductImage
+
+ (product[:images] || [])
+ |> Enum.sort_by(& &1.position)
+ |> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
+ |> Enum.reject(&is_nil/1)
+ |> case do
+ [] -> []
+ urls -> urls
+ end
end
end
diff --git a/lib/simpleshop_theme_web/live/shop/collection.ex b/lib/simpleshop_theme_web/live/shop/collection.ex
index 2e1c021..e16a651 100644
--- a/lib/simpleshop_theme_web/live/shop/collection.ex
+++ b/lib/simpleshop_theme_web/live/shop/collection.ex
@@ -75,10 +75,13 @@ defmodule SimpleshopThemeWeb.Shop.Collection do
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.price)
- defp sort_products(products, "price_desc"), do: Enum.sort_by(products, & &1.price, :desc)
- defp sort_products(products, "name_asc"), do: Enum.sort_by(products, & &1.name)
- defp sort_products(products, "name_desc"), do: Enum.sort_by(products, & &1.name, :desc)
+ 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}"
diff --git a/lib/simpleshop_theme_web/live/shop/product_show.ex b/lib/simpleshop_theme_web/live/shop/product_show.ex
index b88bd6f..2589340 100644
--- a/lib/simpleshop_theme_web/live/shop/product_show.ex
+++ b/lib/simpleshop_theme_web/live/shop/product_show.ex
@@ -2,6 +2,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Cart
+ alias SimpleshopTheme.Products.{Product, ProductImage}
alias SimpleshopTheme.Theme.PreviewData
@impl true
@@ -19,14 +20,13 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
# Build gallery images from local image_id or external URL
gallery_images =
- [
- image_src(product[:image_id], product[:image_url]),
- image_src(product[:hover_image_id], product[:hover_image_url])
- ]
+ (product[:images] || [])
+ |> Enum.sort_by(& &1.position)
+ |> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
|> Enum.reject(&is_nil/1)
# Initialize variant selection
- option_types = product[:option_types] || []
+ 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)
@@ -34,7 +34,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
socket =
socket
- |> assign(:page_title, product.name)
+ |> assign(:page_title, product.title)
|> assign(:product, product)
|> assign(:gallery_images, gallery_images)
|> assign(:related_products, related_products)
@@ -56,16 +56,6 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
List.first(products)
end
- # Build image source URL - prefer local image_id, fall back to external URL
- defp image_src(image_id, _url) when is_binary(image_id) do
- "/images/#{image_id}/variant/1200.webp"
- end
-
- # Mock data uses base paths like "/mockups/product-1" — append size + format
- defp image_src(_, "/mockups/" <> _ = url), do: "#{url}-1200.webp"
- defp image_src(_, url) when is_binary(url), do: url
- defp image_src(_, _), do: nil
-
# Select first available variant by default
defp initialize_variant_selection([first | _] = _variants) do
{first.options, first}
@@ -98,7 +88,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
end
defp variant_price(%{price: price}, _product) when is_integer(price), do: price
- defp variant_price(_, %{price: price}), do: price
+ defp variant_price(_, %{cheapest_price: price}), do: price
defp variant_price(_, _), do: 0
defp find_variant(variants, selected_options) do
@@ -154,7 +144,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
|> SimpleshopThemeWeb.CartHook.broadcast_and_update(cart)
|> assign(:quantity, 1)
|> assign(:cart_drawer_open, true)
- |> assign(:cart_status, "#{socket.assigns.product.name} added to cart")
+ |> assign(:cart_status, "#{socket.assigns.product.title} added to cart")
{:noreply, socket}
else
diff --git a/priv/repo/migrations/20260213005639_add_cached_product_fields.exs b/priv/repo/migrations/20260213005639_add_cached_product_fields.exs
new file mode 100644
index 0000000..9a79bdf
--- /dev/null
+++ b/priv/repo/migrations/20260213005639_add_cached_product_fields.exs
@@ -0,0 +1,12 @@
+defmodule SimpleshopTheme.Repo.Migrations.AddCachedProductFields do
+ use Ecto.Migration
+
+ def change do
+ alter table(:products) do
+ add :cheapest_price, :integer, null: false, default: 0
+ add :compare_at_price, :integer
+ add :in_stock, :boolean, null: false, default: true
+ add :on_sale, :boolean, null: false, default: false
+ end
+ end
+end
diff --git a/test/simpleshop_theme/products/product_image_test.exs b/test/simpleshop_theme/products/product_image_test.exs
index ebcb436..8629928 100644
--- a/test/simpleshop_theme/products/product_image_test.exs
+++ b/test/simpleshop_theme/products/product_image_test.exs
@@ -62,4 +62,48 @@ defmodule SimpleshopTheme.Products.ProductImageTest do
assert changeset.valid?
end
end
+
+ # =============================================================================
+ # Display helpers
+ # =============================================================================
+
+ describe "display_url/2" do
+ test "prefers local image_id over src" do
+ image = %{image_id: "abc-123", src: "https://cdn.example.com/img.jpg"}
+ assert ProductImage.display_url(image) == "/images/abc-123/variant/800.webp"
+ end
+
+ test "accepts custom size" do
+ image = %{image_id: "abc-123", src: "https://cdn.example.com/img.jpg"}
+ assert ProductImage.display_url(image, 400) == "/images/abc-123/variant/400.webp"
+ end
+
+ test "falls back to src when no image_id" do
+ image = %{image_id: nil, src: "https://cdn.example.com/img.jpg"}
+ assert ProductImage.display_url(image) == "https://cdn.example.com/img.jpg"
+ end
+
+ test "returns nil when neither image_id nor src" do
+ assert ProductImage.display_url(%{image_id: nil, src: nil}) == nil
+ end
+
+ test "returns nil for nil input" do
+ assert ProductImage.display_url(nil) == nil
+ end
+ end
+
+ describe "source_width/1" do
+ test "returns source_width from preloaded image" do
+ image = %{image: %{source_width: 2400}}
+ assert ProductImage.source_width(image) == 2400
+ end
+
+ test "returns nil when image not preloaded" do
+ assert ProductImage.source_width(%{}) == nil
+ end
+
+ test "returns nil when source_width is nil" do
+ assert ProductImage.source_width(%{image: %{source_width: nil}}) == nil
+ end
+ end
end
diff --git a/test/simpleshop_theme/products/product_test.exs b/test/simpleshop_theme/products/product_test.exs
index 41d6fdf..0a92f04 100644
--- a/test/simpleshop_theme/products/product_test.exs
+++ b/test/simpleshop_theme/products/product_test.exs
@@ -248,4 +248,64 @@ defmodule SimpleshopTheme.Products.ProductTest do
assert "archived" in statuses
end
end
+
+ # =============================================================================
+ # Display helpers
+ # =============================================================================
+
+ describe "primary_image/1" do
+ test "returns image with lowest position" do
+ product = %{images: [%{position: 2, src: "b.jpg"}, %{position: 0, src: "a.jpg"}]}
+ assert Product.primary_image(product).src == "a.jpg"
+ end
+
+ test "returns nil with no images" do
+ assert Product.primary_image(%{images: []}) == nil
+ end
+
+ test "returns nil when images not present" do
+ assert Product.primary_image(%{}) == nil
+ end
+ end
+
+ describe "hover_image/1" do
+ test "returns second image by position" do
+ product = %{images: [%{position: 0, src: "a.jpg"}, %{position: 1, src: "b.jpg"}]}
+ assert Product.hover_image(product).src == "b.jpg"
+ end
+
+ test "returns nil with fewer than 2 images" do
+ assert Product.hover_image(%{images: [%{position: 0, src: "a.jpg"}]}) == nil
+ end
+
+ test "returns nil with no images" do
+ assert Product.hover_image(%{images: []}) == nil
+ end
+ end
+
+ describe "option_types/1" do
+ test "extracts from provider_data" do
+ product = %{
+ provider_data: %{
+ "options" => [
+ %{"name" => "Size", "values" => [%{"title" => "S"}, %{"title" => "M"}]},
+ %{"name" => "Color", "values" => [%{"title" => "Red"}]}
+ ]
+ }
+ }
+
+ types = Product.option_types(product)
+ assert length(types) == 2
+ assert hd(types) == %{name: "Size", values: ["S", "M"]}
+ end
+
+ test "returns empty list when no provider_data" do
+ assert Product.option_types(%{}) == []
+ end
+
+ test "falls back to option_types field on mock data" do
+ mock = %{option_types: [%{name: "Size", values: ["S", "M"]}]}
+ assert Product.option_types(mock) == [%{name: "Size", values: ["S", "M"]}]
+ end
+ end
end
diff --git a/test/simpleshop_theme/products_test.exs b/test/simpleshop_theme/products_test.exs
index 5ffacb9..6c8ce65 100644
--- a/test/simpleshop_theme/products_test.exs
+++ b/test/simpleshop_theme/products_test.exs
@@ -418,6 +418,289 @@ defmodule SimpleshopTheme.ProductsTest do
end
end
+ # =============================================================================
+ # Storefront queries
+ # =============================================================================
+
+ describe "recompute_cached_fields/1" do
+ test "computes cheapest price from available variants" do
+ product = product_fixture()
+
+ product_variant_fixture(%{
+ product: product,
+ price: 3000,
+ is_enabled: true,
+ is_available: true
+ })
+
+ product_variant_fixture(%{
+ product: product,
+ price: 2000,
+ is_enabled: true,
+ is_available: true
+ })
+
+ product_variant_fixture(%{
+ product: product,
+ price: 1500,
+ is_enabled: false,
+ is_available: true
+ })
+
+ assert {:ok, updated} = Products.recompute_cached_fields(product)
+ assert updated.cheapest_price == 2000
+ end
+
+ test "sets cheapest_price to 0 when no available variants" do
+ product = product_fixture()
+
+ product_variant_fixture(%{
+ product: product,
+ price: 2000,
+ is_enabled: true,
+ is_available: false
+ })
+
+ assert {:ok, updated} = Products.recompute_cached_fields(product)
+ assert updated.cheapest_price == 0
+ end
+
+ test "sets in_stock based on available variants" do
+ product = product_fixture()
+ product_variant_fixture(%{product: product, is_enabled: true, is_available: true})
+
+ assert {:ok, updated} = Products.recompute_cached_fields(product)
+ assert updated.in_stock == true
+ end
+
+ test "sets in_stock false when no available variants" do
+ product = product_fixture()
+ product_variant_fixture(%{product: product, is_enabled: true, is_available: false})
+
+ assert {:ok, updated} = Products.recompute_cached_fields(product)
+ assert updated.in_stock == false
+ end
+
+ test "sets on_sale when any variant has compare_at_price > price" do
+ product = product_fixture()
+ product_variant_fixture(%{product: product, price: 2000, compare_at_price: 3000})
+
+ assert {:ok, updated} = Products.recompute_cached_fields(product)
+ assert updated.on_sale == true
+ end
+
+ test "sets on_sale false when no sale variants" do
+ product = product_fixture()
+ product_variant_fixture(%{product: product, price: 2000, compare_at_price: nil})
+
+ assert {:ok, updated} = Products.recompute_cached_fields(product)
+ assert updated.on_sale == false
+ end
+
+ test "stores compare_at_price from cheapest available variant" do
+ product = product_fixture()
+
+ product_variant_fixture(%{
+ product: product,
+ price: 2000,
+ compare_at_price: 3000,
+ is_enabled: true,
+ is_available: true
+ })
+
+ product_variant_fixture(%{
+ product: product,
+ price: 1500,
+ compare_at_price: 2500,
+ is_enabled: true,
+ is_available: true
+ })
+
+ assert {:ok, updated} = Products.recompute_cached_fields(product)
+ assert updated.cheapest_price == 1500
+ assert updated.compare_at_price == 2500
+ end
+ end
+
+ describe "get_visible_product/1" do
+ test "returns visible active product by slug" do
+ product = product_fixture(%{slug: "test-product", visible: true, status: "active"})
+ found = Products.get_visible_product("test-product")
+ assert found.id == product.id
+ end
+
+ test "returns nil for hidden product" do
+ _product = product_fixture(%{slug: "hidden", visible: false, status: "active"})
+ assert Products.get_visible_product("hidden") == nil
+ end
+
+ test "returns nil for draft product" do
+ _product = product_fixture(%{slug: "draft", visible: true, status: "draft"})
+ assert Products.get_visible_product("draft") == nil
+ end
+
+ test "preloads images and variants" do
+ product = product_fixture(%{slug: "preloaded"})
+ product_image_fixture(%{product: product})
+ product_variant_fixture(%{product: product})
+
+ found = Products.get_visible_product("preloaded")
+ assert length(found.images) == 1
+ assert length(found.variants) == 1
+ end
+ end
+
+ describe "list_visible_products/1" do
+ test "returns only visible active products" do
+ _visible = product_fixture(%{visible: true, status: "active"})
+ _hidden = product_fixture(%{visible: false, status: "active"})
+ _draft = product_fixture(%{visible: true, status: "draft"})
+
+ products = Products.list_visible_products()
+ assert length(products) == 1
+ end
+
+ test "filters by category" do
+ _apparel = product_fixture(%{category: "Apparel"})
+ _home = product_fixture(%{category: "Homewares"})
+
+ products = Products.list_visible_products(category: "Apparel")
+ assert length(products) == 1
+ assert hd(products).category == "Apparel"
+ end
+
+ test "filters by on_sale" do
+ sale = product_fixture()
+ _regular = product_fixture()
+ product_variant_fixture(%{product: sale, price: 2000, compare_at_price: 3000})
+ Products.recompute_cached_fields(sale)
+
+ products = Products.list_visible_products(on_sale: true)
+ assert length(products) == 1
+ assert hd(products).id == sale.id
+ end
+
+ test "filters by in_stock" do
+ in_stock = product_fixture()
+ out_of_stock = product_fixture()
+ product_variant_fixture(%{product: in_stock, is_enabled: true, is_available: true})
+ product_variant_fixture(%{product: out_of_stock, is_enabled: true, is_available: false})
+ Products.recompute_cached_fields(in_stock)
+ Products.recompute_cached_fields(out_of_stock)
+
+ products = Products.list_visible_products(in_stock: true)
+ assert length(products) == 1
+ assert hd(products).id == in_stock.id
+ end
+
+ test "sorts by price ascending" do
+ cheap = product_fixture()
+ expensive = product_fixture()
+
+ product_variant_fixture(%{
+ product: cheap,
+ price: 1000,
+ is_enabled: true,
+ is_available: true
+ })
+
+ product_variant_fixture(%{
+ product: expensive,
+ price: 5000,
+ is_enabled: true,
+ is_available: true
+ })
+
+ Products.recompute_cached_fields(cheap)
+ Products.recompute_cached_fields(expensive)
+
+ products = Products.list_visible_products(sort: "price_asc")
+ assert Enum.map(products, & &1.id) == [cheap.id, expensive.id]
+ end
+
+ test "sorts by price descending" do
+ cheap = product_fixture()
+ expensive = product_fixture()
+
+ product_variant_fixture(%{
+ product: cheap,
+ price: 1000,
+ is_enabled: true,
+ is_available: true
+ })
+
+ product_variant_fixture(%{
+ product: expensive,
+ price: 5000,
+ is_enabled: true,
+ is_available: true
+ })
+
+ Products.recompute_cached_fields(cheap)
+ Products.recompute_cached_fields(expensive)
+
+ products = Products.list_visible_products(sort: "price_desc")
+ assert Enum.map(products, & &1.id) == [expensive.id, cheap.id]
+ end
+
+ test "sorts by name ascending" do
+ b = product_fixture(%{title: "Banana"})
+ a = product_fixture(%{title: "Apple"})
+
+ products = Products.list_visible_products(sort: "name_asc")
+ assert Enum.map(products, & &1.id) == [a.id, b.id]
+ end
+
+ test "limits results" do
+ for _ <- 1..5, do: product_fixture()
+
+ products = Products.list_visible_products(limit: 3)
+ assert length(products) == 3
+ end
+
+ test "excludes a product by ID" do
+ p1 = product_fixture()
+ p2 = product_fixture()
+
+ products = Products.list_visible_products(exclude: p1.id)
+ assert length(products) == 1
+ assert hd(products).id == p2.id
+ end
+
+ test "preloads images but not variants" do
+ product = product_fixture()
+ product_image_fixture(%{product: product})
+ product_variant_fixture(%{product: product})
+
+ [loaded] = Products.list_visible_products()
+ assert length(loaded.images) == 1
+ assert %Ecto.Association.NotLoaded{} = loaded.variants
+ end
+ end
+
+ describe "list_categories/0" do
+ test "returns distinct categories from visible products" do
+ product_fixture(%{category: "Apparel"})
+ product_fixture(%{category: "Homewares"})
+ product_fixture(%{category: "Apparel"})
+ product_fixture(%{category: nil})
+
+ categories = Products.list_categories()
+ assert length(categories) == 2
+ assert Enum.map(categories, & &1.name) == ["Apparel", "Homewares"]
+ assert Enum.map(categories, & &1.slug) == ["apparel", "homewares"]
+ end
+
+ test "excludes categories from hidden products" do
+ product_fixture(%{category: "Visible", visible: true})
+ product_fixture(%{category: "Hidden", visible: false})
+
+ categories = Products.list_categories()
+ assert length(categories) == 1
+ assert hd(categories).name == "Visible"
+ end
+ end
+
describe "sync_product_variants/2" do
test "creates new variants" do
product = product_fixture()
diff --git a/test/simpleshop_theme/theme/preview_data_test.exs b/test/simpleshop_theme/theme/preview_data_test.exs
index beaadc6..ebf854c 100644
--- a/test/simpleshop_theme/theme/preview_data_test.exs
+++ b/test/simpleshop_theme/theme/preview_data_test.exs
@@ -17,10 +17,10 @@ defmodule SimpleshopTheme.Theme.PreviewDataTest do
assert is_map(product)
assert Map.has_key?(product, :id)
- assert Map.has_key?(product, :name)
+ assert Map.has_key?(product, :title)
assert Map.has_key?(product, :description)
- assert Map.has_key?(product, :price)
- assert Map.has_key?(product, :image_url)
+ assert Map.has_key?(product, :cheapest_price)
+ assert Map.has_key?(product, :images)
assert Map.has_key?(product, :category)
assert Map.has_key?(product, :in_stock)
assert Map.has_key?(product, :on_sale)
@@ -30,30 +30,26 @@ defmodule SimpleshopTheme.Theme.PreviewDataTest do
products = PreviewData.products()
for product <- products do
- assert is_integer(product.price)
- assert product.price > 0
+ assert is_integer(product.cheapest_price)
+ assert product.cheapest_price > 0
if product.compare_at_price do
assert is_integer(product.compare_at_price)
- assert product.compare_at_price > product.price
+ assert product.compare_at_price > product.cheapest_price
end
end
end
- test "products have image URLs" do
+ test "products have images" do
products = PreviewData.products()
for product <- products do
- assert is_binary(product.image_url)
- # Images can be either local paths (starting with /) or full URLs
- assert String.starts_with?(product.image_url, "/") or
- String.starts_with?(product.image_url, "http")
+ assert is_list(product.images)
+ assert length(product.images) >= 1
- if product.hover_image_url do
- assert is_binary(product.hover_image_url)
-
- assert String.starts_with?(product.hover_image_url, "/") or
- String.starts_with?(product.hover_image_url, "http")
+ for image <- product.images do
+ assert is_integer(image.position)
+ assert is_binary(image.src) or not is_nil(image.image_id)
end
end
end
@@ -109,8 +105,8 @@ defmodule SimpleshopTheme.Theme.PreviewDataTest do
product = item.product
assert is_map(product)
assert Map.has_key?(product, :id)
- assert Map.has_key?(product, :name)
- assert Map.has_key?(product, :price)
+ assert Map.has_key?(product, :title)
+ assert Map.has_key?(product, :cheapest_price)
end
end
end
diff --git a/test/simpleshop_theme_web/live/shop/collection_test.exs b/test/simpleshop_theme_web/live/shop/collection_test.exs
index 1020012..6411b9f 100644
--- a/test/simpleshop_theme_web/live/shop/collection_test.exs
+++ b/test/simpleshop_theme_web/live/shop/collection_test.exs
@@ -32,7 +32,7 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
products = PreviewData.products()
first_product = List.first(products)
- assert html =~ first_product.name
+ assert html =~ first_product.title
end
test "displays category filter buttons", %{conn: conn} do
@@ -71,7 +71,7 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
products = PreviewData.products_by_category(category.slug)
for product <- products do
- assert html =~ product.name
+ assert html =~ product.title
end
end
end
diff --git a/test/simpleshop_theme_web/live/shop/home_test.exs b/test/simpleshop_theme_web/live/shop/home_test.exs
index 4804f48..52eed54 100644
--- a/test/simpleshop_theme_web/live/shop/home_test.exs
+++ b/test/simpleshop_theme_web/live/shop/home_test.exs
@@ -43,7 +43,7 @@ defmodule SimpleshopThemeWeb.Shop.HomeTest do
products = PreviewData.products()
first_product = List.first(products)
- assert html =~ first_product.name
+ assert html =~ first_product.title
end
test "renders image and text section", %{conn: conn} do
diff --git a/test/simpleshop_theme_web/live/shop/product_show_test.exs b/test/simpleshop_theme_web/live/shop/product_show_test.exs
index 1055246..37ba70e 100644
--- a/test/simpleshop_theme_web/live/shop/product_show_test.exs
+++ b/test/simpleshop_theme_web/live/shop/product_show_test.exs
@@ -17,7 +17,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
product = List.first(PreviewData.products())
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
- assert html =~ product.name
+ assert html =~ product.title
end
test "renders product description", %{conn: conn} do
@@ -31,7 +31,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
product = List.first(PreviewData.products())
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
- assert html =~ SimpleshopTheme.Cart.format_price(product.price)
+ assert html =~ SimpleshopTheme.Cart.format_price(product.cheapest_price)
end
test "renders breadcrumb with category link", %{conn: conn} do
@@ -55,7 +55,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
# Should show other products, not the current one
other_product = Enum.at(PreviewData.products(), 1)
- assert html =~ other_product.name
+ assert html =~ other_product.title
end
end
@@ -213,8 +213,8 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
product = List.first(PreviewData.products())
# Each image should have descriptive alt text
- assert html =~ "#{product.name} — image 1 of"
- assert html =~ "#{product.name} — image 2 of"
+ assert html =~ "#{product.title} — image 1 of"
+ assert html =~ "#{product.title} — image 2 of"
end
test "renders dot indicators for multi-image gallery", %{conn: conn} do
@@ -278,14 +278,14 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
product = Enum.at(PreviewData.products(), 1)
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
- assert html =~ product.name
+ assert html =~ product.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.name
+ assert html =~ first_product.title
end
end
end