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