From 35e0386abba95d73e9c3aba7d100e4b3fd29e0fa Mon Sep 17 00:00:00 2001 From: jamey Date: Fri, 13 Feb 2026 01:26:39 +0000 Subject: [PATCH] add denormalized product fields and use Product structs throughout Adds cheapest_price, compare_at_price, in_stock, on_sale columns to products table (recomputed from variants after each sync). Shop components now work with Product structs directly instead of plain maps from PreviewData. Renames .name to .title, adds Product display helpers (primary_image, hover_image, option_types) and ProductImage helpers (display_url, direct_url, source_width). Adds Products context query functions for storefront use (list_visible_products, get_visible_product, list_categories with DB-level sort/filter). Co-Authored-By: Claude Opus 4.6 --- lib/simpleshop_theme/cart.ex | 11 +- lib/simpleshop_theme/products.ex | 109 ++++ lib/simpleshop_theme/products/product.ex | 55 ++ .../products/product_image.ex | 36 ++ .../sync/product_sync_worker.ex | 3 + lib/simpleshop_theme/theme/preview_data.ex | 479 ++++++++++-------- .../components/page_templates/pdp.html.heex | 6 +- .../components/shop_components/cart.ex | 14 +- .../components/shop_components/product.ex | 110 ++-- .../live/admin/theme/index.ex | 17 +- .../live/shop/collection.ex | 11 +- .../live/shop/product_show.ex | 26 +- ...260213005639_add_cached_product_fields.exs | 12 + .../products/product_image_test.exs | 44 ++ .../products/product_test.exs | 60 +++ test/simpleshop_theme/products_test.exs | 283 +++++++++++ .../theme/preview_data_test.exs | 32 +- .../live/shop/collection_test.exs | 4 +- .../live/shop/home_test.exs | 2 +- .../live/shop/product_show_test.exs | 14 +- 20 files changed, 1000 insertions(+), 328 deletions(-) create mode 100644 priv/repo/migrations/20260213005639_add_cached_product_fields.exs diff --git a/lib/simpleshop_theme/cart.ex b/lib/simpleshop_theme/cart.ex index 2c45649..4f0c3e7 100644 --- a/lib/simpleshop_theme/cart.ex +++ b/lib/simpleshop_theme/cart.ex @@ -8,6 +8,7 @@ defmodule SimpleshopTheme.Cart do """ alias SimpleshopTheme.Products + alias SimpleshopTheme.Products.{Product, ProductImage} @session_key "cart" @@ -179,17 +180,13 @@ defmodule SimpleshopTheme.Cart do (product[:variants] || []) |> Enum.filter(fn v -> MapSet.member?(ids_set, v.id) end) |> Enum.map(fn v -> - image = - case product[:image_url] do - "/mockups/" <> _ = url -> "#{url}-400.webp" - url -> url - end + image = ProductImage.direct_url(Product.primary_image(product), 400) {v.id, %{ variant_id: v.id, - product_id: product.id, - name: product.name, + product_id: product[:id], + name: product.title, variant: format_variant_options(v.options), price: v.price, quantity: 1, diff --git a/lib/simpleshop_theme/products.ex b/lib/simpleshop_theme/products.ex index dd5c11e..6d12288 100644 --- a/lib/simpleshop_theme/products.ex +++ b/lib/simpleshop_theme/products.ex @@ -108,6 +108,115 @@ defmodule SimpleshopTheme.Products do SimpleshopTheme.Sync.ProductSyncWorker.enqueue(conn.id) end + # ============================================================================= + # Storefront queries + # ============================================================================= + + # Listing pages only need images (price/stock are denormalized on product) + @listing_preloads [images: :image] + # Detail page also needs variants for the variant selector + @detail_preloads [images: :image, variants: []] + + @doc """ + Gets a single visible, active product by slug with full preloads (for detail page). + """ + def get_visible_product(slug) do + Product + |> where([p], p.slug == ^slug and p.visible == true and p.status == "active") + |> preload(^@detail_preloads) + |> Repo.one() + end + + @doc """ + Lists visible, active products with listing preloads (no variants). + + ## Options + + * `:sort` - sort order: "price_asc", "price_desc", "newest", "name_asc", "name_desc" + * `:category` - filter by category name + * `:on_sale` - if true, only products on sale + * `:in_stock` - if true, only products in stock + * `:limit` - max number of results + * `:exclude` - product ID to exclude + + """ + def list_visible_products(opts \\ []) do + Product + |> where([p], p.visible == true and p.status == "active") + |> apply_visible_filters(opts) + |> apply_sort(opts[:sort]) + |> maybe_limit(opts[:limit]) + |> maybe_exclude(opts[:exclude]) + |> preload(^@listing_preloads) + |> Repo.all() + end + + @doc """ + Lists distinct categories from visible, active products. + Returns a list of %{name: "Category", slug: "category"}. + """ + def list_categories do + from(p in Product, + where: p.visible == true and p.status == "active" and not is_nil(p.category), + select: p.category, + distinct: true, + order_by: p.category + ) + |> Repo.all() + |> Enum.map(fn name -> %{name: name, slug: Slug.slugify(name)} end) + end + + @doc """ + Recomputes denormalized fields from a product's variants. + Called after variant sync to keep cached fields up to date. + """ + def recompute_cached_fields(%Product{} = product) do + variants = Repo.all(from v in ProductVariant, where: v.product_id == ^product.id) + + available = Enum.filter(variants, &(&1.is_enabled and &1.is_available)) + cheapest = Enum.min_by(available, & &1.price, fn -> nil end) + + attrs = %{ + cheapest_price: if(cheapest, do: cheapest.price, else: 0), + compare_at_price: if(cheapest, do: cheapest.compare_at_price), + in_stock: available != [], + on_sale: Enum.any?(variants, &ProductVariant.on_sale?/1) + } + + product + |> Product.recompute_changeset(attrs) + |> Repo.update() + end + + defp apply_visible_filters(query, opts) do + query + |> maybe_filter_category(opts[:category]) + |> maybe_filter_on_sale(opts[:on_sale]) + |> maybe_filter_in_stock(opts[:in_stock]) + end + + defp maybe_filter_category(query, nil), do: query + defp maybe_filter_category(query, name), do: where(query, [p], p.category == ^name) + + defp maybe_filter_on_sale(query, true), do: where(query, [p], p.on_sale == true) + defp maybe_filter_on_sale(query, _), do: query + + defp maybe_filter_in_stock(query, true), do: where(query, [p], p.in_stock == true) + defp maybe_filter_in_stock(query, _), do: query + + defp apply_sort(query, "price_asc"), do: order_by(query, [p], asc: p.cheapest_price) + defp apply_sort(query, "price_desc"), do: order_by(query, [p], desc: p.cheapest_price) + defp apply_sort(query, "newest"), do: order_by(query, [p], desc: p.inserted_at) + defp apply_sort(query, "name_asc"), do: order_by(query, [p], asc: p.title) + defp apply_sort(query, "name_desc"), do: order_by(query, [p], desc: p.title) + defp apply_sort(query, _), do: order_by(query, [p], desc: p.inserted_at) + + defp maybe_limit(query, nil), do: query + defp maybe_limit(query, n) when is_integer(n), do: limit(query, ^n) + + defp maybe_exclude(query, nil), do: query + defp maybe_exclude(query, id), do: where(query, [p], p.id != ^id) + # ============================================================================= # Products # ============================================================================= diff --git a/lib/simpleshop_theme/products/product.ex b/lib/simpleshop_theme/products/product.ex index 694956d..005ac1f 100644 --- a/lib/simpleshop_theme/products/product.ex +++ b/lib/simpleshop_theme/products/product.ex @@ -25,6 +25,12 @@ defmodule SimpleshopTheme.Products.Product do field :provider_data, :map, default: %{} field :checksum, :string + # Denormalized from variants — recomputed by Products.recompute_cached_fields/1 + field :cheapest_price, :integer, default: 0 + field :compare_at_price, :integer + field :in_stock, :boolean, default: true + field :on_sale, :boolean, default: false + belongs_to :provider_connection, SimpleshopTheme.Products.ProviderConnection has_many :images, SimpleshopTheme.Products.ProductImage has_many :variants, SimpleshopTheme.Products.ProductVariant @@ -61,6 +67,55 @@ defmodule SimpleshopTheme.Products.Product do |> unique_constraint([:provider_connection_id, :provider_product_id]) end + @doc """ + Changeset for recomputing denormalized fields from variants. + """ + def recompute_changeset(product, attrs) do + product + |> cast(attrs, [:cheapest_price, :compare_at_price, :in_stock, :on_sale]) + end + + # --------------------------------------------------------------------------- + # Display helpers + # --------------------------------------------------------------------------- + + @doc """ + Returns the primary (first by position) image, or nil. + Works with preloaded images association or plain maps. + """ + def primary_image(%{images: images}) when is_list(images) do + Enum.min_by(images, & &1.position, fn -> nil end) + end + + def primary_image(_), do: nil + + @doc """ + Returns the second image by position (hover image), or nil. + """ + def hover_image(%{images: images}) when is_list(images) and length(images) >= 2 do + images + |> Enum.sort_by(& &1.position) + |> Enum.at(1) + end + + def hover_image(_), do: nil + + @doc """ + Extracts option types from provider_data. + Returns a list of %{name: "Size", values: ["S", "M", "L"]}. + """ + 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"]) + } + end) + end + + def option_types(%{option_types: option_types}) when is_list(option_types), do: option_types + def option_types(_), do: [] + @doc """ Generates a checksum from provider data for detecting changes. """ diff --git a/lib/simpleshop_theme/products/product_image.ex b/lib/simpleshop_theme/products/product_image.ex index ca5e5df..3e4bc79 100644 --- a/lib/simpleshop_theme/products/product_image.ex +++ b/lib/simpleshop_theme/products/product_image.ex @@ -33,4 +33,40 @@ defmodule SimpleshopTheme.Products.ProductImage do |> foreign_key_constraint(:product_id) |> foreign_key_constraint(:image_id) end + + # --------------------------------------------------------------------------- + # Display helpers + # --------------------------------------------------------------------------- + + @doc """ + Returns the display URL for a product image at the given size. + Prefers local image_id (responsive optimized), falls back to CDN src. + """ + def display_url(image, size \\ 800) + + def display_url(%{image_id: id}, size) when not is_nil(id), + do: "/images/#{id}/variant/#{size}.webp" + + def display_url(%{src: src}, _size) when is_binary(src), do: src + def display_url(_, _), do: nil + + @doc """ + Returns a fully resolved URL for an image at the given size. + Unlike `display_url/2`, handles mockup URL patterns that need size suffixes. + Use for `` tags where the URL must resolve to an actual file. + """ + def direct_url(image, size \\ 800) + + def direct_url(%{image_id: id}, size) when not is_nil(id), + do: "/images/#{id}/variant/#{size}.webp" + + def direct_url(%{src: "/mockups/" <> _ = src}, size), do: "#{src}-#{size}.webp" + def direct_url(%{src: src}, _size) when is_binary(src), do: src + def direct_url(_, _), do: nil + + @doc """ + Returns the source width from the linked Media.Image, if preloaded. + """ + def source_width(%{image: %{source_width: w}}) when not is_nil(w), do: w + def source_width(_), do: nil end diff --git a/lib/simpleshop_theme/sync/product_sync_worker.ex b/lib/simpleshop_theme/sync/product_sync_worker.ex index 1fd925a..df4f2cd 100644 --- a/lib/simpleshop_theme/sync/product_sync_worker.ex +++ b/lib/simpleshop_theme/sync/product_sync_worker.ex @@ -192,5 +192,8 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do end) Products.sync_product_variants(product, variants) + + # Recompute denormalized fields (cheapest_price, in_stock, on_sale) from variants + Products.recompute_cached_fields(product) end end diff --git a/lib/simpleshop_theme/theme/preview_data.ex b/lib/simpleshop_theme/theme/preview_data.ex index d5e7322..ce3ec66 100644 --- a/lib/simpleshop_theme/theme/preview_data.ex +++ b/lib/simpleshop_theme/theme/preview_data.ex @@ -363,7 +363,6 @@ defmodule SimpleshopTheme.Theme.PreviewData do status: "active", preload: [images: :image, variants: []] ) - |> Enum.map(&product_to_map/1) end defp get_real_categories do @@ -383,129 +382,30 @@ defmodule SimpleshopTheme.Theme.PreviewData do |> Enum.sort_by(& &1.name) end - # Transform a Product struct to the map format expected by shop components - defp product_to_map(product) do - # Get images sorted by position - images = Enum.sort_by(product.images, & &1.position) - first_image = List.first(images) - second_image = Enum.at(images, 1) - - # Get available variants for pricing - available_variants = - product.variants - |> Enum.filter(&(&1.is_enabled and &1.is_available)) - - # Get the cheapest available variant for display price - cheapest_variant = - available_variants - |> Enum.min_by(& &1.price, fn -> nil end) - - # Determine stock and sale status - in_stock = Enum.any?(available_variants) - on_sale = Enum.any?(product.variants, &SimpleshopTheme.Products.ProductVariant.on_sale?/1) - - # Use local image if available, fall back to CDN URL - {image_url, image_id, source_width} = image_attrs(first_image) - {hover_image_url, hover_image_id, hover_source_width} = image_attrs(second_image) - - # Map variants to frontend format (only enabled/published ones) - variants = - product.variants - |> Enum.filter(& &1.is_enabled) - |> Enum.map(fn v -> - %{ - id: v.id, - provider_variant_id: v.provider_variant_id, - title: v.title, - price: v.price, - compare_at_price: v.compare_at_price, - options: v.options, - is_available: v.is_available - } - end) - - # Extract option types, filtered to only values present in enabled variants - option_types = - SimpleshopTheme.Providers.Printify.extract_option_types(product.provider_data) - |> filter_option_types_by_variants(variants) - - %{ - id: product.slug, - name: product.title, - description: product.description, - price: if(cheapest_variant, do: cheapest_variant.price, else: 0), - compare_at_price: if(cheapest_variant, do: cheapest_variant.compare_at_price, else: nil), - image_url: image_url, - image_id: image_id, - hover_image_url: hover_image_url, - hover_image_id: hover_image_id, - source_width: source_width, - hover_source_width: hover_source_width, - category: product.category, - slug: product.slug, - in_stock: in_stock, - on_sale: on_sale, - inserted_at: product.inserted_at, - option_types: option_types, - variants: variants - } - end - - # Extract image attributes, preferring local Media.Image when available - defp image_attrs(nil), do: {nil, nil, nil} - - defp image_attrs(%{image_id: image_id, image: %{source_width: source_width}}) - when not is_nil(image_id) do - # Local image available - use image_id for responsive element - {nil, image_id, source_width} - end - - defp image_attrs(%{src: src}) do - # Fall back to CDN URL - {src, nil, nil} - end - - # Filter option types to only include values present in enabled variants - # This ensures we don't show unpublished options from the Printify catalog - defp filter_option_types_by_variants(option_types, variants) do - # Collect all option values present in the enabled variants - values_in_use = - Enum.reduce(variants, %{}, fn variant, acc -> - Enum.reduce(variant.options, acc, fn {opt_name, opt_value}, inner_acc -> - Map.update(inner_acc, opt_name, MapSet.new([opt_value]), &MapSet.put(&1, opt_value)) - end) - end) - - # Filter each option type's values to only those in use - option_types - |> Enum.map(fn opt_type -> - used_values = Map.get(values_in_use, opt_type.name, MapSet.new()) - - filtered_values = - opt_type.values - |> Enum.filter(fn v -> MapSet.member?(used_values, v.title) end) - - %{opt_type | values: filtered_values} - end) - |> Enum.reject(fn opt_type -> opt_type.values == [] end) - end - - # Default source width for mockup variants (max generated size) - @mockup_source_width 1200 - defp mock_products do [ # Art Prints %{ id: "1", - name: "Mountain Sunrise Art Print", + slug: "1", + title: "Mountain Sunrise Art Print", description: "Capture the magic of dawn with this stunning mountain landscape print", - price: 1999, + cheapest_price: 1999, compare_at_price: nil, - image_url: "/mockups/mountain-sunrise-print-1", - hover_image_url: "/mockups/mountain-sunrise-print-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/mountain-sunrise-print-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/mountain-sunrise-print-2", + image: %{source_width: 1200} + } + ], category: "Art Prints", in_stock: true, on_sale: false, @@ -546,14 +446,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do }, %{ id: "2", - name: "Ocean Waves Art Print", + slug: "2", + title: "Ocean Waves Art Print", description: "A calming sunset over ocean waves to bring peace to any room", - price: 2400, + cheapest_price: 2400, compare_at_price: nil, - image_url: "/mockups/ocean-waves-print-1", - hover_image_url: "/mockups/ocean-waves-print-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/ocean-waves-print-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/ocean-waves-print-2", + image: %{source_width: 1200} + } + ], category: "Art Prints", in_stock: true, on_sale: false, @@ -572,14 +483,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do }, %{ id: "3", - name: "Wildflower Meadow Art Print", + slug: "3", + title: "Wildflower Meadow Art Print", description: "Beautiful wildflower meadow captured in the summer sunshine", - price: 2400, + cheapest_price: 2400, compare_at_price: nil, - image_url: "/mockups/wildflower-meadow-print-1", - hover_image_url: "/mockups/wildflower-meadow-print-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/wildflower-meadow-print-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/wildflower-meadow-print-2", + image: %{source_width: 1200} + } + ], category: "Art Prints", in_stock: true, on_sale: false, @@ -598,14 +520,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do }, %{ id: "4", - name: "Geometric Abstract Art Print", + slug: "4", + title: "Geometric Abstract Art Print", description: "Modern minimalist design with bold geometric shapes", - price: 2800, + cheapest_price: 2800, compare_at_price: 3200, - image_url: "/mockups/geometric-abstract-print-1", - hover_image_url: "/mockups/geometric-abstract-print-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/geometric-abstract-print-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/geometric-abstract-print-2", + image: %{source_width: 1200} + } + ], category: "Art Prints", in_stock: true, on_sale: true, @@ -625,14 +558,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do }, %{ id: "5", - name: "Botanical Illustration Print", + slug: "5", + title: "Botanical Illustration Print", description: "Vintage-inspired botanical drawing with intricate detail", - price: 2400, + cheapest_price: 2400, compare_at_price: nil, - image_url: "/mockups/botanical-illustration-print-1", - hover_image_url: "/mockups/botanical-illustration-print-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/botanical-illustration-print-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/botanical-illustration-print-2", + image: %{source_width: 1200} + } + ], category: "Art Prints", in_stock: true, on_sale: false, @@ -652,14 +596,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do # Apparel %{ id: "6", - name: "Forest Silhouette T-Shirt", + slug: "6", + title: "Forest Silhouette T-Shirt", description: "Soft cotton tee featuring a peaceful forest silhouette design", - price: 2999, + cheapest_price: 2999, compare_at_price: nil, - image_url: "/mockups/forest-silhouette-tshirt-1", - hover_image_url: "/mockups/forest-silhouette-tshirt-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/forest-silhouette-tshirt-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/forest-silhouette-tshirt-2", + image: %{source_width: 1200} + } + ], category: "Apparel", in_stock: true, on_sale: false, @@ -835,14 +790,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do }, %{ id: "7", - name: "Forest Light Hoodie", + slug: "7", + title: "Forest Light Hoodie", description: "Cosy fleece hoodie with stunning forest light photography", - price: 4499, + cheapest_price: 4499, compare_at_price: 4999, - image_url: "/mockups/forest-light-hoodie-1", - hover_image_url: "/mockups/forest-light-hoodie-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/forest-light-hoodie-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/forest-light-hoodie-2", + image: %{source_width: 1200} + } + ], category: "Apparel", in_stock: true, on_sale: true, @@ -882,14 +848,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do }, %{ id: "8", - name: "Wildflower Meadow Tote Bag", + slug: "8", + title: "Wildflower Meadow Tote Bag", description: "Sturdy cotton tote bag with vibrant wildflower design", - price: 1999, + cheapest_price: 1999, compare_at_price: nil, - image_url: "/mockups/wildflower-meadow-tote-1", - hover_image_url: "/mockups/wildflower-meadow-tote-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/wildflower-meadow-tote-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/wildflower-meadow-tote-2", + image: %{source_width: 1200} + } + ], category: "Apparel", in_stock: true, on_sale: false, @@ -899,14 +876,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do }, %{ id: "9", - name: "Sunset Gradient Tote Bag", + slug: "9", + title: "Sunset Gradient Tote Bag", description: "Beautiful ocean sunset printed on durable canvas tote", - price: 1999, + cheapest_price: 1999, compare_at_price: nil, - image_url: "/mockups/sunset-gradient-tote-1", - hover_image_url: "/mockups/sunset-gradient-tote-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/sunset-gradient-tote-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/sunset-gradient-tote-2", + image: %{source_width: 1200} + } + ], category: "Apparel", in_stock: true, on_sale: false, @@ -917,14 +905,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do # Homewares %{ id: "10", - name: "Fern Leaf Mug", + slug: "10", + title: "Fern Leaf Mug", description: "Start your morning right with this nature-inspired ceramic mug", - price: 1499, + cheapest_price: 1499, compare_at_price: nil, - image_url: "/mockups/fern-leaf-mug-1", - hover_image_url: "/mockups/fern-leaf-mug-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/fern-leaf-mug-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/fern-leaf-mug-2", + image: %{source_width: 1200} + } + ], category: "Homewares", in_stock: true, on_sale: false, @@ -950,14 +949,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do }, %{ id: "11", - name: "Ocean Waves Cushion", + slug: "11", + title: "Ocean Waves Cushion", description: "Soft polyester cushion featuring a stunning ocean sunset", - price: 2999, + cheapest_price: 2999, compare_at_price: nil, - image_url: "/mockups/ocean-waves-cushion-1", - hover_image_url: "/mockups/ocean-waves-cushion-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/ocean-waves-cushion-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/ocean-waves-cushion-2", + image: %{source_width: 1200} + } + ], category: "Homewares", in_stock: true, on_sale: false, @@ -967,14 +977,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do }, %{ id: "12", - name: "Night Sky Blanket", + slug: "12", + title: "Night Sky Blanket", description: "Cosy sherpa fleece blanket with mesmerising milky way print", - price: 5999, + cheapest_price: 5999, compare_at_price: 6999, - image_url: "/mockups/night-sky-blanket-1", - hover_image_url: "/mockups/night-sky-blanket-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/night-sky-blanket-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/night-sky-blanket-2", + image: %{source_width: 1200} + } + ], category: "Homewares", in_stock: true, on_sale: true, @@ -992,14 +1013,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do # Stationery %{ id: "13", - name: "Autumn Leaves Notebook", + slug: "13", + title: "Autumn Leaves Notebook", description: "Hardcover journal with beautiful autumn foliage design", - price: 1999, + cheapest_price: 1999, compare_at_price: nil, - image_url: "/mockups/autumn-leaves-notebook-1", - hover_image_url: "/mockups/autumn-leaves-notebook-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/autumn-leaves-notebook-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/autumn-leaves-notebook-2", + image: %{source_width: 1200} + } + ], category: "Stationery", in_stock: true, on_sale: false, @@ -1009,14 +1041,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do }, %{ id: "14", - name: "Monstera Leaf Notebook", + slug: "14", + title: "Monstera Leaf Notebook", description: "Tropical-inspired hardcover journal for your thoughts", - price: 1999, + cheapest_price: 1999, compare_at_price: nil, - image_url: "/mockups/monstera-leaf-notebook-1", - hover_image_url: "/mockups/monstera-leaf-notebook-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/monstera-leaf-notebook-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/monstera-leaf-notebook-2", + image: %{source_width: 1200} + } + ], category: "Stationery", in_stock: true, on_sale: false, @@ -1027,14 +1070,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do # Accessories %{ id: "15", - name: "Monstera Leaf Phone Case", + slug: "15", + title: "Monstera Leaf Phone Case", description: "Tough phone case with stunning monstera leaf photography", - price: 2499, + cheapest_price: 2499, compare_at_price: nil, - image_url: "/mockups/monstera-leaf-phone-case-1", - hover_image_url: "/mockups/monstera-leaf-phone-case-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/monstera-leaf-phone-case-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/monstera-leaf-phone-case-2", + image: %{source_width: 1200} + } + ], category: "Accessories", in_stock: true, on_sale: false, @@ -1044,14 +1098,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do }, %{ id: "16", - name: "Blue Waves Laptop Sleeve", + slug: "16", + title: "Blue Waves Laptop Sleeve", description: "Protective laptop sleeve with abstract blue gradient design", - price: 3499, + cheapest_price: 3499, compare_at_price: nil, - image_url: "/mockups/blue-waves-laptop-sleeve-1", - hover_image_url: "/mockups/blue-waves-laptop-sleeve-2", - source_width: @mockup_source_width, - hover_source_width: @mockup_source_width, + images: [ + %{ + position: 0, + image_id: nil, + src: "/mockups/blue-waves-laptop-sleeve-1", + image: %{source_width: 1200} + }, + %{ + position: 1, + image_id: nil, + src: "/mockups/blue-waves-laptop-sleeve-2", + image: %{source_width: 1200} + } + ], category: "Accessories", in_stock: true, on_sale: false, diff --git a/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex b/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex index 14d9f6d..1f731fa 100644 --- a/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex +++ b/lib/simpleshop_theme_web/components/page_templates/pdp.html.heex @@ -25,16 +25,16 @@ else [] end ++ - [%{label: @product.name, current: true}] + [%{label: @product.title, current: true}] } mode={@mode} />
- <.product_gallery images={@gallery_images} product_name={@product.name} /> + <.product_gallery images={@gallery_images} product_name={@product.title} />
- <.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.name} + {@item.product.title}

{@item.variant} @@ -398,13 +400,17 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do

- {SimpleshopTheme.Cart.format_price(@item.product.price * @item.quantity)} + {SimpleshopTheme.Cart.format_price(@item.product.cheapest_price * @item.quantity)}

""" 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"""
@@ -103,26 +108,28 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do <% end %> <%= if @has_hover_image do %>
<.product_card_image - product={@product} + image={@primary_image} + alt={@product.title} variant={@variant} priority={@priority} class="product-image-primary w-full h-full object-cover transition-opacity duration-300" /> <.product_card_image - product={@product} + image={@hover_image} + alt={@product.title} variant={@variant} - image_key={:hover} class="product-image-hover w-full h-full object-cover" />
<% else %> <.product_card_image - product={@product} + image={@primary_image} + alt={@product.title} variant={@variant} priority={@priority} class="product-image-primary w-full h-full object-cover" @@ -164,7 +171,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do class="stretched-link" style="color: inherit; text-decoration: none;" > - {@product.name} + {@product.title} <% else %> <.link @@ -172,11 +179,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do class="stretched-link" style="color: inherit; text-decoration: none;" > - {@product.name} + {@product.title} <% end %> <% else %> - {@product.name} + {@product.title} <% end %> <%= if @theme_settings.show_prices do %> @@ -191,41 +198,34 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do """ end - # Helper to render product images with responsive variants. - # Works for both mockups (static files) and database images. - # Requires source_width to be set for responsive image support. - attr :product, :map, required: true + attr :image, :map, default: nil + attr :alt, :string, required: true attr :variant, :atom, required: true attr :priority, :boolean, default: false attr :class, :string, default: "" - attr :image_key, :atom, default: :primary defp product_card_image(assigns) do - # Determine which image fields to use based on primary vs hover - {image_id_field, image_url_field, source_width_field} = - case assigns.image_key do - :hover -> {:hover_image_id, :hover_image_url, :hover_source_width} - _ -> {:image_id, :image_url, :source_width} - end + image = assigns.image - # Build the base image path: - # - Database images: /images/{id}/variant - # - Mockup images: {image_url} (e.g., /mockups/product-1) - image_id = assigns.product[image_id_field] - image_url = assigns.product[image_url_field] + {src, source_width} = + cond do + is_nil(image) -> + {nil, nil} - src = - if image_id do - # Trailing slash so build_srcset produces /images/{id}/variant/800.webp - "/images/#{image_id}/variant/" - else - image_url + image[:image_id] -> + {"/images/#{image.image_id}/variant/", ProductImage.source_width(image)} + + image[:src] -> + {image[:src], ProductImage.source_width(image)} + + true -> + {nil, nil} end assigns = assigns |> assign(:src, src) - |> assign(:source_width, assigns.product[source_width_field]) + |> assign(:source_width, source_width) ~H""" <%= cond do %> @@ -234,7 +234,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do class={[@class, "flex items-center justify-center"]} style="color: var(--t-text-tertiary);" role="img" - aria-label={@product.name} + aria-label={@alt} > %> <.responsive_image src={@src} - alt={@product.name} + alt={@alt} source_width={@source_width} sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px" class={@class} @@ -267,7 +267,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do <% true -> %> {@product.name} - {SimpleshopTheme.Cart.format_price(@product.price)} + {SimpleshopTheme.Cart.format_price(@product.cheapest_price)} {SimpleshopTheme.Cart.format_price(@product.compare_at_price)} <% else %> - {SimpleshopTheme.Cart.format_price(@product.price)} + {SimpleshopTheme.Cart.format_price(@product.cheapest_price)} <% end %>
@@ -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)}

<% :minimal -> %>

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