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 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-13 01:26:39 +00:00
parent 0b4fe031b7
commit 35e0386abb
20 changed files with 1000 additions and 328 deletions

View File

@ -8,6 +8,7 @@ defmodule SimpleshopTheme.Cart do
""" """
alias SimpleshopTheme.Products alias SimpleshopTheme.Products
alias SimpleshopTheme.Products.{Product, ProductImage}
@session_key "cart" @session_key "cart"
@ -179,17 +180,13 @@ defmodule SimpleshopTheme.Cart do
(product[:variants] || []) (product[:variants] || [])
|> Enum.filter(fn v -> MapSet.member?(ids_set, v.id) end) |> Enum.filter(fn v -> MapSet.member?(ids_set, v.id) end)
|> Enum.map(fn v -> |> Enum.map(fn v ->
image = image = ProductImage.direct_url(Product.primary_image(product), 400)
case product[:image_url] do
"/mockups/" <> _ = url -> "#{url}-400.webp"
url -> url
end
{v.id, {v.id,
%{ %{
variant_id: v.id, variant_id: v.id,
product_id: product.id, product_id: product[:id],
name: product.name, name: product.title,
variant: format_variant_options(v.options), variant: format_variant_options(v.options),
price: v.price, price: v.price,
quantity: 1, quantity: 1,

View File

@ -108,6 +108,115 @@ defmodule SimpleshopTheme.Products do
SimpleshopTheme.Sync.ProductSyncWorker.enqueue(conn.id) SimpleshopTheme.Sync.ProductSyncWorker.enqueue(conn.id)
end 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 # Products
# ============================================================================= # =============================================================================

View File

@ -25,6 +25,12 @@ defmodule SimpleshopTheme.Products.Product do
field :provider_data, :map, default: %{} field :provider_data, :map, default: %{}
field :checksum, :string 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 belongs_to :provider_connection, SimpleshopTheme.Products.ProviderConnection
has_many :images, SimpleshopTheme.Products.ProductImage has_many :images, SimpleshopTheme.Products.ProductImage
has_many :variants, SimpleshopTheme.Products.ProductVariant has_many :variants, SimpleshopTheme.Products.ProductVariant
@ -61,6 +67,55 @@ defmodule SimpleshopTheme.Products.Product do
|> unique_constraint([:provider_connection_id, :provider_product_id]) |> unique_constraint([:provider_connection_id, :provider_product_id])
end 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 """ @doc """
Generates a checksum from provider data for detecting changes. Generates a checksum from provider data for detecting changes.
""" """

View File

@ -33,4 +33,40 @@ defmodule SimpleshopTheme.Products.ProductImage do
|> foreign_key_constraint(:product_id) |> foreign_key_constraint(:product_id)
|> foreign_key_constraint(:image_id) |> foreign_key_constraint(:image_id)
end 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 `<img>` 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 end

View File

@ -192,5 +192,8 @@ defmodule SimpleshopTheme.Sync.ProductSyncWorker do
end) end)
Products.sync_product_variants(product, variants) Products.sync_product_variants(product, variants)
# Recompute denormalized fields (cheapest_price, in_stock, on_sale) from variants
Products.recompute_cached_fields(product)
end end
end end

View File

@ -363,7 +363,6 @@ defmodule SimpleshopTheme.Theme.PreviewData do
status: "active", status: "active",
preload: [images: :image, variants: []] preload: [images: :image, variants: []]
) )
|> Enum.map(&product_to_map/1)
end end
defp get_real_categories do defp get_real_categories do
@ -383,129 +382,30 @@ defmodule SimpleshopTheme.Theme.PreviewData do
|> Enum.sort_by(& &1.name) |> Enum.sort_by(& &1.name)
end 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 <picture> 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 defp mock_products do
[ [
# Art Prints # Art Prints
%{ %{
id: "1", 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", description: "Capture the magic of dawn with this stunning mountain landscape print",
price: 1999, cheapest_price: 1999,
compare_at_price: nil, compare_at_price: nil,
image_url: "/mockups/mountain-sunrise-print-1", images: [
hover_image_url: "/mockups/mountain-sunrise-print-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Art Prints",
in_stock: true, in_stock: true,
on_sale: false, on_sale: false,
@ -546,14 +446,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
}, },
%{ %{
id: "2", 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", description: "A calming sunset over ocean waves to bring peace to any room",
price: 2400, cheapest_price: 2400,
compare_at_price: nil, compare_at_price: nil,
image_url: "/mockups/ocean-waves-print-1", images: [
hover_image_url: "/mockups/ocean-waves-print-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Art Prints",
in_stock: true, in_stock: true,
on_sale: false, on_sale: false,
@ -572,14 +483,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
}, },
%{ %{
id: "3", id: "3",
name: "Wildflower Meadow Art Print", slug: "3",
title: "Wildflower Meadow Art Print",
description: "Beautiful wildflower meadow captured in the summer sunshine", description: "Beautiful wildflower meadow captured in the summer sunshine",
price: 2400, cheapest_price: 2400,
compare_at_price: nil, compare_at_price: nil,
image_url: "/mockups/wildflower-meadow-print-1", images: [
hover_image_url: "/mockups/wildflower-meadow-print-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Art Prints",
in_stock: true, in_stock: true,
on_sale: false, on_sale: false,
@ -598,14 +520,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
}, },
%{ %{
id: "4", id: "4",
name: "Geometric Abstract Art Print", slug: "4",
title: "Geometric Abstract Art Print",
description: "Modern minimalist design with bold geometric shapes", description: "Modern minimalist design with bold geometric shapes",
price: 2800, cheapest_price: 2800,
compare_at_price: 3200, compare_at_price: 3200,
image_url: "/mockups/geometric-abstract-print-1", images: [
hover_image_url: "/mockups/geometric-abstract-print-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Art Prints",
in_stock: true, in_stock: true,
on_sale: true, on_sale: true,
@ -625,14 +558,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
}, },
%{ %{
id: "5", id: "5",
name: "Botanical Illustration Print", slug: "5",
title: "Botanical Illustration Print",
description: "Vintage-inspired botanical drawing with intricate detail", description: "Vintage-inspired botanical drawing with intricate detail",
price: 2400, cheapest_price: 2400,
compare_at_price: nil, compare_at_price: nil,
image_url: "/mockups/botanical-illustration-print-1", images: [
hover_image_url: "/mockups/botanical-illustration-print-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Art Prints",
in_stock: true, in_stock: true,
on_sale: false, on_sale: false,
@ -652,14 +596,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
# Apparel # Apparel
%{ %{
id: "6", id: "6",
name: "Forest Silhouette T-Shirt", slug: "6",
title: "Forest Silhouette T-Shirt",
description: "Soft cotton tee featuring a peaceful forest silhouette design", description: "Soft cotton tee featuring a peaceful forest silhouette design",
price: 2999, cheapest_price: 2999,
compare_at_price: nil, compare_at_price: nil,
image_url: "/mockups/forest-silhouette-tshirt-1", images: [
hover_image_url: "/mockups/forest-silhouette-tshirt-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Apparel",
in_stock: true, in_stock: true,
on_sale: false, on_sale: false,
@ -835,14 +790,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
}, },
%{ %{
id: "7", id: "7",
name: "Forest Light Hoodie", slug: "7",
title: "Forest Light Hoodie",
description: "Cosy fleece hoodie with stunning forest light photography", description: "Cosy fleece hoodie with stunning forest light photography",
price: 4499, cheapest_price: 4499,
compare_at_price: 4999, compare_at_price: 4999,
image_url: "/mockups/forest-light-hoodie-1", images: [
hover_image_url: "/mockups/forest-light-hoodie-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Apparel",
in_stock: true, in_stock: true,
on_sale: true, on_sale: true,
@ -882,14 +848,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
}, },
%{ %{
id: "8", id: "8",
name: "Wildflower Meadow Tote Bag", slug: "8",
title: "Wildflower Meadow Tote Bag",
description: "Sturdy cotton tote bag with vibrant wildflower design", description: "Sturdy cotton tote bag with vibrant wildflower design",
price: 1999, cheapest_price: 1999,
compare_at_price: nil, compare_at_price: nil,
image_url: "/mockups/wildflower-meadow-tote-1", images: [
hover_image_url: "/mockups/wildflower-meadow-tote-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Apparel",
in_stock: true, in_stock: true,
on_sale: false, on_sale: false,
@ -899,14 +876,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
}, },
%{ %{
id: "9", id: "9",
name: "Sunset Gradient Tote Bag", slug: "9",
title: "Sunset Gradient Tote Bag",
description: "Beautiful ocean sunset printed on durable canvas tote", description: "Beautiful ocean sunset printed on durable canvas tote",
price: 1999, cheapest_price: 1999,
compare_at_price: nil, compare_at_price: nil,
image_url: "/mockups/sunset-gradient-tote-1", images: [
hover_image_url: "/mockups/sunset-gradient-tote-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Apparel",
in_stock: true, in_stock: true,
on_sale: false, on_sale: false,
@ -917,14 +905,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
# Homewares # Homewares
%{ %{
id: "10", id: "10",
name: "Fern Leaf Mug", slug: "10",
title: "Fern Leaf Mug",
description: "Start your morning right with this nature-inspired ceramic mug", description: "Start your morning right with this nature-inspired ceramic mug",
price: 1499, cheapest_price: 1499,
compare_at_price: nil, compare_at_price: nil,
image_url: "/mockups/fern-leaf-mug-1", images: [
hover_image_url: "/mockups/fern-leaf-mug-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Homewares",
in_stock: true, in_stock: true,
on_sale: false, on_sale: false,
@ -950,14 +949,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
}, },
%{ %{
id: "11", id: "11",
name: "Ocean Waves Cushion", slug: "11",
title: "Ocean Waves Cushion",
description: "Soft polyester cushion featuring a stunning ocean sunset", description: "Soft polyester cushion featuring a stunning ocean sunset",
price: 2999, cheapest_price: 2999,
compare_at_price: nil, compare_at_price: nil,
image_url: "/mockups/ocean-waves-cushion-1", images: [
hover_image_url: "/mockups/ocean-waves-cushion-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Homewares",
in_stock: true, in_stock: true,
on_sale: false, on_sale: false,
@ -967,14 +977,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
}, },
%{ %{
id: "12", id: "12",
name: "Night Sky Blanket", slug: "12",
title: "Night Sky Blanket",
description: "Cosy sherpa fleece blanket with mesmerising milky way print", description: "Cosy sherpa fleece blanket with mesmerising milky way print",
price: 5999, cheapest_price: 5999,
compare_at_price: 6999, compare_at_price: 6999,
image_url: "/mockups/night-sky-blanket-1", images: [
hover_image_url: "/mockups/night-sky-blanket-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Homewares",
in_stock: true, in_stock: true,
on_sale: true, on_sale: true,
@ -992,14 +1013,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
# Stationery # Stationery
%{ %{
id: "13", id: "13",
name: "Autumn Leaves Notebook", slug: "13",
title: "Autumn Leaves Notebook",
description: "Hardcover journal with beautiful autumn foliage design", description: "Hardcover journal with beautiful autumn foliage design",
price: 1999, cheapest_price: 1999,
compare_at_price: nil, compare_at_price: nil,
image_url: "/mockups/autumn-leaves-notebook-1", images: [
hover_image_url: "/mockups/autumn-leaves-notebook-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Stationery",
in_stock: true, in_stock: true,
on_sale: false, on_sale: false,
@ -1009,14 +1041,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
}, },
%{ %{
id: "14", id: "14",
name: "Monstera Leaf Notebook", slug: "14",
title: "Monstera Leaf Notebook",
description: "Tropical-inspired hardcover journal for your thoughts", description: "Tropical-inspired hardcover journal for your thoughts",
price: 1999, cheapest_price: 1999,
compare_at_price: nil, compare_at_price: nil,
image_url: "/mockups/monstera-leaf-notebook-1", images: [
hover_image_url: "/mockups/monstera-leaf-notebook-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Stationery",
in_stock: true, in_stock: true,
on_sale: false, on_sale: false,
@ -1027,14 +1070,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
# Accessories # Accessories
%{ %{
id: "15", id: "15",
name: "Monstera Leaf Phone Case", slug: "15",
title: "Monstera Leaf Phone Case",
description: "Tough phone case with stunning monstera leaf photography", description: "Tough phone case with stunning monstera leaf photography",
price: 2499, cheapest_price: 2499,
compare_at_price: nil, compare_at_price: nil,
image_url: "/mockups/monstera-leaf-phone-case-1", images: [
hover_image_url: "/mockups/monstera-leaf-phone-case-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Accessories",
in_stock: true, in_stock: true,
on_sale: false, on_sale: false,
@ -1044,14 +1098,25 @@ defmodule SimpleshopTheme.Theme.PreviewData do
}, },
%{ %{
id: "16", id: "16",
name: "Blue Waves Laptop Sleeve", slug: "16",
title: "Blue Waves Laptop Sleeve",
description: "Protective laptop sleeve with abstract blue gradient design", description: "Protective laptop sleeve with abstract blue gradient design",
price: 3499, cheapest_price: 3499,
compare_at_price: nil, compare_at_price: nil,
image_url: "/mockups/blue-waves-laptop-sleeve-1", images: [
hover_image_url: "/mockups/blue-waves-laptop-sleeve-2", %{
source_width: @mockup_source_width, position: 0,
hover_source_width: @mockup_source_width, 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", category: "Accessories",
in_stock: true, in_stock: true,
on_sale: false, on_sale: false,

View File

@ -25,16 +25,16 @@
else else
[] []
end ++ end ++
[%{label: @product.name, current: true}] [%{label: @product.title, current: true}]
} }
mode={@mode} mode={@mode}
/> />
<div class="grid grid-cols-1 md:grid-cols-2 gap-12 mb-16"> <div class="grid grid-cols-1 md:grid-cols-2 gap-12 mb-16">
<.product_gallery images={@gallery_images} product_name={@product.name} /> <.product_gallery images={@gallery_images} product_name={@product.title} />
<div> <div>
<.product_info product={Map.put(@product, :price, @display_price)} /> <.product_info product={@product} display_price={@display_price} />
<%!-- Dynamic variant selectors --%> <%!-- Dynamic variant selectors --%>
<%= for option_type <- @option_types do %> <%= for option_type <- @option_types do %>

View File

@ -5,6 +5,8 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
import SimpleshopThemeWeb.ShopComponents.Base import SimpleshopThemeWeb.ShopComponents.Base
alias SimpleshopTheme.Products.{Product, ProductImage}
defp close_cart_drawer_js do defp close_cart_drawer_js do
Phoenix.LiveView.JS.push("close_cart_drawer") Phoenix.LiveView.JS.push("close_cart_drawer")
end end
@ -355,8 +357,8 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
style="border-radius: var(--t-radius-image);" style="border-radius: var(--t-radius-image);"
> >
<img <img
src={@item.product.image_url} src={cart_item_image(@item.product)}
alt={@item.product.name} alt={@item.product.title}
width="96" width="96"
height="96" height="96"
loading="lazy" loading="lazy"
@ -369,7 +371,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
class="font-semibold mb-1" class="font-semibold mb-1"
style="font-family: var(--t-font-heading); color: var(--t-text-primary);" style="font-family: var(--t-font-heading); color: var(--t-text-primary);"
> >
{@item.product.name} {@item.product.title}
</h3> </h3>
<p class="text-sm mb-2" style="color: var(--t-text-secondary);"> <p class="text-sm mb-2" style="color: var(--t-text-secondary);">
{@item.variant} {@item.variant}
@ -398,13 +400,17 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
<div class="text-right"> <div class="text-right">
<p class="font-bold text-lg" style="color: var(--t-text-primary);"> <p class="font-bold text-lg" style="color: var(--t-text-primary);">
{SimpleshopTheme.Cart.format_price(@item.product.price * @item.quantity)} {SimpleshopTheme.Cart.format_price(@item.product.cheapest_price * @item.quantity)}
</p> </p>
</div> </div>
</.shop_card> </.shop_card>
""" """
end end
defp cart_item_image(product) do
ProductImage.direct_url(Product.primary_image(product), 400)
end
@doc """ @doc """
Renders the order summary card. Renders the order summary card.

View File

@ -4,12 +4,14 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
import SimpleshopThemeWeb.ShopComponents.Base import SimpleshopThemeWeb.ShopComponents.Base
import SimpleshopThemeWeb.ShopComponents.Content, only: [responsive_image: 1] import SimpleshopThemeWeb.ShopComponents.Content, only: [responsive_image: 1]
alias SimpleshopTheme.Products.{Product, ProductImage}
@doc """ @doc """
Renders a product card with configurable variants. Renders a product card with configurable variants.
## Attributes ## 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. * `theme_settings` - Required. The theme settings map.
* `mode` - Either `:live` (default) or `:preview`. * `mode` - Either `:live` (default) or `:preview`.
* `variant` - The visual variant: * `variant` - The visual variant:
@ -89,12 +91,15 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
attr :mode, :atom, default: :live attr :mode, :atom, default: :live
defp product_card_inner(assigns) do defp product_card_inner(assigns) do
product = assigns.product
primary_image = Product.primary_image(product)
hover_image = Product.hover_image(product)
assigns = assigns =
assign( assigns
assigns, |> assign(:primary_image, primary_image)
:has_hover_image, |> assign(:hover_image, hover_image)
assigns.theme_settings.hover_image && assigns.product[:hover_image_url] |> assign(:has_hover_image, assigns.theme_settings.hover_image && hover_image != nil)
)
~H""" ~H"""
<div class={image_container_classes(@variant)} style="z-index: 1;"> <div class={image_container_classes(@variant)} style="z-index: 1;">
@ -103,26 +108,28 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
<% end %> <% end %>
<%= if @has_hover_image do %> <%= if @has_hover_image do %>
<div <div
id={"product-image-scroll-#{@product[:id] || @product.name}"} id={"product-image-scroll-#{@product[:id] || @product.title}"}
class="product-image-scroll" class="product-image-scroll"
phx-hook="ProductImageScroll" phx-hook="ProductImageScroll"
> >
<.product_card_image <.product_card_image
product={@product} image={@primary_image}
alt={@product.title}
variant={@variant} variant={@variant}
priority={@priority} priority={@priority}
class="product-image-primary w-full h-full object-cover transition-opacity duration-300" class="product-image-primary w-full h-full object-cover transition-opacity duration-300"
/> />
<.product_card_image <.product_card_image
product={@product} image={@hover_image}
alt={@product.title}
variant={@variant} variant={@variant}
image_key={:hover}
class="product-image-hover w-full h-full object-cover" class="product-image-hover w-full h-full object-cover"
/> />
</div> </div>
<% else %> <% else %>
<.product_card_image <.product_card_image
product={@product} image={@primary_image}
alt={@product.title}
variant={@variant} variant={@variant}
priority={@priority} priority={@priority}
class="product-image-primary w-full h-full object-cover" class="product-image-primary w-full h-full object-cover"
@ -164,7 +171,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
class="stretched-link" class="stretched-link"
style="color: inherit; text-decoration: none;" style="color: inherit; text-decoration: none;"
> >
{@product.name} {@product.title}
</a> </a>
<% else %> <% else %>
<.link <.link
@ -172,11 +179,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
class="stretched-link" class="stretched-link"
style="color: inherit; text-decoration: none;" style="color: inherit; text-decoration: none;"
> >
{@product.name} {@product.title}
</.link> </.link>
<% end %> <% end %>
<% else %> <% else %>
{@product.name} {@product.title}
<% end %> <% end %>
</h3> </h3>
<%= if @theme_settings.show_prices do %> <%= if @theme_settings.show_prices do %>
@ -191,41 +198,34 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
""" """
end end
# Helper to render product images with responsive variants. attr :image, :map, default: nil
# Works for both mockups (static files) and database images. attr :alt, :string, required: true
# Requires source_width to be set for responsive image support.
attr :product, :map, required: true
attr :variant, :atom, required: true attr :variant, :atom, required: true
attr :priority, :boolean, default: false attr :priority, :boolean, default: false
attr :class, :string, default: "" attr :class, :string, default: ""
attr :image_key, :atom, default: :primary
defp product_card_image(assigns) do defp product_card_image(assigns) do
# Determine which image fields to use based on primary vs hover image = assigns.image
{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
# Build the base image path: {src, source_width} =
# - Database images: /images/{id}/variant cond do
# - Mockup images: {image_url} (e.g., /mockups/product-1) is_nil(image) ->
image_id = assigns.product[image_id_field] {nil, nil}
image_url = assigns.product[image_url_field]
src = image[:image_id] ->
if image_id do {"/images/#{image.image_id}/variant/", ProductImage.source_width(image)}
# Trailing slash so build_srcset produces /images/{id}/variant/800.webp
"/images/#{image_id}/variant/" image[:src] ->
else {image[:src], ProductImage.source_width(image)}
image_url
true ->
{nil, nil}
end end
assigns = assigns =
assigns assigns
|> assign(:src, src) |> assign(:src, src)
|> assign(:source_width, assigns.product[source_width_field]) |> assign(:source_width, source_width)
~H""" ~H"""
<%= cond do %> <%= cond do %>
@ -234,7 +234,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
class={[@class, "flex items-center justify-center"]} class={[@class, "flex items-center justify-center"]}
style="color: var(--t-text-tertiary);" style="color: var(--t-text-tertiary);"
role="img" role="img"
aria-label={@product.name} aria-label={@alt}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -256,7 +256,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
<% @source_width -> %> <% @source_width -> %>
<.responsive_image <.responsive_image
src={@src} src={@src}
alt={@product.name} alt={@alt}
source_width={@source_width} source_width={@source_width}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px" sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px"
class={@class} class={@class}
@ -267,7 +267,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
<% true -> %> <% true -> %>
<img <img
src={@src} src={@src}
alt={@product.name} alt={@alt}
width="600" width="600"
height="600" height="600"
loading={if @priority, do: nil, else: "lazy"} loading={if @priority, do: nil, else: "lazy"}
@ -307,14 +307,14 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
class="text-lg font-bold" class="text-lg font-bold"
style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));" 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(@product.cheapest_price)}
</span> </span>
<span class="text-sm line-through ml-2" style="color: var(--t-text-tertiary);"> <span class="text-sm line-through ml-2" style="color: var(--t-text-tertiary);">
{SimpleshopTheme.Cart.format_price(@product.compare_at_price)} {SimpleshopTheme.Cart.format_price(@product.compare_at_price)}
</span> </span>
<% else %> <% else %>
<span class="text-lg font-bold" style="color: var(--t-text-primary);"> <span class="text-lg font-bold" style="color: var(--t-text-primary);">
{SimpleshopTheme.Cart.format_price(@product.price)} {SimpleshopTheme.Cart.format_price(@product.cheapest_price)}
</span> </span>
<% end %> <% end %>
</div> </div>
@ -325,15 +325,15 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
{SimpleshopTheme.Cart.format_price(@product.compare_at_price)} {SimpleshopTheme.Cart.format_price(@product.compare_at_price)}
</span> </span>
<% end %> <% end %>
{SimpleshopTheme.Cart.format_price(@product.price)} {SimpleshopTheme.Cart.format_price(@product.cheapest_price)}
</p> </p>
<% :compact -> %> <% :compact -> %>
<p class="font-bold" style="color: var(--t-text-primary);"> <p class="font-bold" style="color: var(--t-text-primary);">
{SimpleshopTheme.Cart.format_price(@product.price)} {SimpleshopTheme.Cart.format_price(@product.cheapest_price)}
</p> </p>
<% :minimal -> %> <% :minimal -> %>
<p class="text-xs" style="color: var(--t-text-secondary);"> <p class="text-xs" style="color: var(--t-text-secondary);">
{SimpleshopTheme.Cart.format_price(@product.price)} {SimpleshopTheme.Cart.format_price(@product.cheapest_price)}
</p> </p>
<% end %> <% end %>
""" """
@ -1130,7 +1130,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
## Examples ## 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 :images, :list, required: true
attr :product_name, :string, required: true attr :product_name, :string, required: true
@ -1401,23 +1401,27 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
## Attributes ## Attributes
* `product` - Required. Product map with `name`, `price`, `on_sale`, `compare_at_price`. * `product` - Required. Product struct with `title`, `cheapest_price`, `on_sale`, `compare_at_price`.
* `currency` - Optional. Currency symbol. Defaults to "£". * `display_price` - Optional. Override price to display (e.g. selected variant price).
## Examples ## Examples
<.product_info product={@product} /> <.product_info product={@product} />
<.product_info product={@product} display_price={@display_price} />
""" """
attr :product, :map, required: true attr :product, :map, required: true
attr :display_price, :integer, default: nil
def product_info(assigns) do def product_info(assigns) do
assigns = assign(assigns, :price, assigns.display_price || assigns.product.cheapest_price)
~H""" ~H"""
<div> <div>
<h1 <h1
class="text-3xl md:text-4xl font-bold mb-4" class="text-3xl md:text-4xl font-bold mb-4"
style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking);" style="font-family: var(--t-font-heading); color: var(--t-text-primary); font-weight: var(--t-heading-weight); letter-spacing: var(--t-heading-tracking);"
> >
{@product.name} {@product.title}
</h1> </h1>
<div class="flex items-center gap-4 mb-6"> <div class="flex items-center gap-4 mb-6">
@ -1426,7 +1430,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
class="text-3xl font-bold" class="text-3xl font-bold"
style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));" 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)}
</span> </span>
<span class="text-xl line-through" style="color: var(--t-text-tertiary);"> <span class="text-xl line-through" style="color: var(--t-text-tertiary);">
{SimpleshopTheme.Cart.format_price(@product.compare_at_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" class="px-2 py-1 text-sm font-bold text-white rounded"
style="background-color: var(--t-sale-color);" style="background-color: var(--t-sale-color);"
> >
SAVE {round( SAVE {round((@product.compare_at_price - @price) / @product.compare_at_price * 100)}%
(@product.compare_at_price - @product.price) / @product.compare_at_price * 100
)}%
</span> </span>
<% else %> <% else %>
<span class="text-3xl font-bold" style="color: var(--t-text-primary);"> <span class="text-3xl font-bold" style="color: var(--t-text-primary);">
{SimpleshopTheme.Cart.format_price(@product.price)} {SimpleshopTheme.Cart.format_price(@price)}
</span> </span>
<% end %> <% end %>
</div> </div>

View File

@ -353,7 +353,7 @@ defmodule SimpleshopThemeWeb.Admin.Theme.Index do
end) end)
display_price = 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 =
assigns assigns
@ -374,7 +374,9 @@ defmodule SimpleshopThemeWeb.Admin.Theme.Index do
cart_items = assigns.preview_data.cart_items cart_items = assigns.preview_data.cart_items
subtotal = 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 =
assigns assigns
@ -464,6 +466,15 @@ defmodule SimpleshopThemeWeb.Admin.Theme.Index do
end end
defp build_gallery_images(product) do 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
end end

View File

@ -75,10 +75,13 @@ defmodule SimpleshopThemeWeb.Shop.Collection do
defp sort_products(products, "featured"), do: products defp sort_products(products, "featured"), do: products
defp sort_products(products, "newest"), do: Enum.reverse(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_asc"), do: Enum.sort_by(products, & &1.cheapest_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, "price_desc"),
defp sort_products(products, "name_desc"), do: Enum.sort_by(products, & &1.name, :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 sort_products(products, _), do: products
defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}" defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}"

View File

@ -2,6 +2,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
use SimpleshopThemeWeb, :live_view use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Cart alias SimpleshopTheme.Cart
alias SimpleshopTheme.Products.{Product, ProductImage}
alias SimpleshopTheme.Theme.PreviewData alias SimpleshopTheme.Theme.PreviewData
@impl true @impl true
@ -19,14 +20,13 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
# Build gallery images from local image_id or external URL # Build gallery images from local image_id or external URL
gallery_images = gallery_images =
[ (product[:images] || [])
image_src(product[:image_id], product[:image_url]), |> Enum.sort_by(& &1.position)
image_src(product[:hover_image_id], product[:hover_image_url]) |> Enum.map(fn img -> ProductImage.direct_url(img, 1200) end)
]
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
# Initialize variant selection # Initialize variant selection
option_types = product[:option_types] || [] option_types = Product.option_types(product)
variants = product[:variants] || [] variants = product[:variants] || []
{selected_options, selected_variant} = initialize_variant_selection(variants) {selected_options, selected_variant} = initialize_variant_selection(variants)
available_options = compute_available_options(option_types, variants, selected_options) available_options = compute_available_options(option_types, variants, selected_options)
@ -34,7 +34,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
socket = socket =
socket socket
|> assign(:page_title, product.name) |> assign(:page_title, product.title)
|> assign(:product, product) |> assign(:product, product)
|> assign(:gallery_images, gallery_images) |> assign(:gallery_images, gallery_images)
|> assign(:related_products, related_products) |> assign(:related_products, related_products)
@ -56,16 +56,6 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
List.first(products) List.first(products)
end 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 # Select first available variant by default
defp initialize_variant_selection([first | _] = _variants) do defp initialize_variant_selection([first | _] = _variants) do
{first.options, first} {first.options, first}
@ -98,7 +88,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
end end
defp variant_price(%{price: price}, _product) when is_integer(price), do: price 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 variant_price(_, _), do: 0
defp find_variant(variants, selected_options) do defp find_variant(variants, selected_options) do
@ -154,7 +144,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShow do
|> SimpleshopThemeWeb.CartHook.broadcast_and_update(cart) |> SimpleshopThemeWeb.CartHook.broadcast_and_update(cart)
|> assign(:quantity, 1) |> assign(:quantity, 1)
|> assign(:cart_drawer_open, true) |> 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} {:noreply, socket}
else else

View File

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

View File

@ -62,4 +62,48 @@ defmodule SimpleshopTheme.Products.ProductImageTest do
assert changeset.valid? assert changeset.valid?
end end
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 end

View File

@ -248,4 +248,64 @@ defmodule SimpleshopTheme.Products.ProductTest do
assert "archived" in statuses assert "archived" in statuses
end end
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 end

View File

@ -418,6 +418,289 @@ defmodule SimpleshopTheme.ProductsTest do
end end
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 describe "sync_product_variants/2" do
test "creates new variants" do test "creates new variants" do
product = product_fixture() product = product_fixture()

View File

@ -17,10 +17,10 @@ defmodule SimpleshopTheme.Theme.PreviewDataTest do
assert is_map(product) assert is_map(product)
assert Map.has_key?(product, :id) 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, :description)
assert Map.has_key?(product, :price) assert Map.has_key?(product, :cheapest_price)
assert Map.has_key?(product, :image_url) assert Map.has_key?(product, :images)
assert Map.has_key?(product, :category) assert Map.has_key?(product, :category)
assert Map.has_key?(product, :in_stock) assert Map.has_key?(product, :in_stock)
assert Map.has_key?(product, :on_sale) assert Map.has_key?(product, :on_sale)
@ -30,30 +30,26 @@ defmodule SimpleshopTheme.Theme.PreviewDataTest do
products = PreviewData.products() products = PreviewData.products()
for product <- products do for product <- products do
assert is_integer(product.price) assert is_integer(product.cheapest_price)
assert product.price > 0 assert product.cheapest_price > 0
if product.compare_at_price do if product.compare_at_price do
assert is_integer(product.compare_at_price) 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 end
end end
test "products have image URLs" do test "products have images" do
products = PreviewData.products() products = PreviewData.products()
for product <- products do for product <- products do
assert is_binary(product.image_url) assert is_list(product.images)
# Images can be either local paths (starting with /) or full URLs assert length(product.images) >= 1
assert String.starts_with?(product.image_url, "/") or
String.starts_with?(product.image_url, "http")
if product.hover_image_url do for image <- product.images do
assert is_binary(product.hover_image_url) assert is_integer(image.position)
assert is_binary(image.src) or not is_nil(image.image_id)
assert String.starts_with?(product.hover_image_url, "/") or
String.starts_with?(product.hover_image_url, "http")
end end
end end
end end
@ -109,8 +105,8 @@ defmodule SimpleshopTheme.Theme.PreviewDataTest do
product = item.product product = item.product
assert is_map(product) assert is_map(product)
assert Map.has_key?(product, :id) assert Map.has_key?(product, :id)
assert Map.has_key?(product, :name) assert Map.has_key?(product, :title)
assert Map.has_key?(product, :price) assert Map.has_key?(product, :cheapest_price)
end end
end end
end end

View File

@ -32,7 +32,7 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
products = PreviewData.products() products = PreviewData.products()
first_product = List.first(products) first_product = List.first(products)
assert html =~ first_product.name assert html =~ first_product.title
end end
test "displays category filter buttons", %{conn: conn} do test "displays category filter buttons", %{conn: conn} do
@ -71,7 +71,7 @@ defmodule SimpleshopThemeWeb.Shop.CollectionTest do
products = PreviewData.products_by_category(category.slug) products = PreviewData.products_by_category(category.slug)
for product <- products do for product <- products do
assert html =~ product.name assert html =~ product.title
end end
end end
end end

View File

@ -43,7 +43,7 @@ defmodule SimpleshopThemeWeb.Shop.HomeTest do
products = PreviewData.products() products = PreviewData.products()
first_product = List.first(products) first_product = List.first(products)
assert html =~ first_product.name assert html =~ first_product.title
end end
test "renders image and text section", %{conn: conn} do test "renders image and text section", %{conn: conn} do

View File

@ -17,7 +17,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
product = List.first(PreviewData.products()) product = List.first(PreviewData.products())
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}") {:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
assert html =~ product.name assert html =~ product.title
end end
test "renders product description", %{conn: conn} do test "renders product description", %{conn: conn} do
@ -31,7 +31,7 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
product = List.first(PreviewData.products()) product = List.first(PreviewData.products())
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}") {: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 end
test "renders breadcrumb with category link", %{conn: conn} do 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 # Should show other products, not the current one
other_product = Enum.at(PreviewData.products(), 1) other_product = Enum.at(PreviewData.products(), 1)
assert html =~ other_product.name assert html =~ other_product.title
end end
end end
@ -213,8 +213,8 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
product = List.first(PreviewData.products()) product = List.first(PreviewData.products())
# Each image should have descriptive alt text # Each image should have descriptive alt text
assert html =~ "#{product.name} — image 1 of" assert html =~ "#{product.title} — image 1 of"
assert html =~ "#{product.name} — image 2 of" assert html =~ "#{product.title} — image 2 of"
end end
test "renders dot indicators for multi-image gallery", %{conn: conn} do test "renders dot indicators for multi-image gallery", %{conn: conn} do
@ -278,14 +278,14 @@ defmodule SimpleshopThemeWeb.Shop.ProductShowTest do
product = Enum.at(PreviewData.products(), 1) product = Enum.at(PreviewData.products(), 1)
{:ok, _view, html} = live(conn, ~p"/products/#{product.id}") {:ok, _view, html} = live(conn, ~p"/products/#{product.id}")
assert html =~ product.name assert html =~ product.title
end end
test "falls back to first product for unknown ID", %{conn: conn} do test "falls back to first product for unknown ID", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/products/nonexistent") {:ok, _view, html} = live(conn, ~p"/products/nonexistent")
first_product = List.first(PreviewData.products()) first_product = List.first(PreviewData.products())
assert html =~ first_product.name assert html =~ first_product.title
end end
end end
end end