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:
@@ -25,16 +25,16 @@
|
||||
else
|
||||
[]
|
||||
end ++
|
||||
[%{label: @product.name, current: true}]
|
||||
[%{label: @product.title, current: true}]
|
||||
}
|
||||
mode={@mode}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<.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 %>
|
||||
|
||||
@@ -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);"
|
||||
>
|
||||
<img
|
||||
src={@item.product.image_url}
|
||||
alt={@item.product.name}
|
||||
src={cart_item_image(@item.product)}
|
||||
alt={@item.product.title}
|
||||
width="96"
|
||||
height="96"
|
||||
loading="lazy"
|
||||
@@ -369,7 +371,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
|
||||
class="font-semibold mb-1"
|
||||
style="font-family: var(--t-font-heading); color: var(--t-text-primary);"
|
||||
>
|
||||
{@item.product.name}
|
||||
{@item.product.title}
|
||||
</h3>
|
||||
<p class="text-sm mb-2" style="color: var(--t-text-secondary);">
|
||||
{@item.variant}
|
||||
@@ -398,13 +400,17 @@ defmodule SimpleshopThemeWeb.ShopComponents.Cart do
|
||||
|
||||
<div class="text-right">
|
||||
<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>
|
||||
</div>
|
||||
</.shop_card>
|
||||
"""
|
||||
end
|
||||
|
||||
defp cart_item_image(product) do
|
||||
ProductImage.direct_url(Product.primary_image(product), 400)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders the order summary card.
|
||||
|
||||
|
||||
@@ -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"""
|
||||
<div class={image_container_classes(@variant)} style="z-index: 1;">
|
||||
@@ -103,26 +108,28 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
<% end %>
|
||||
<%= if @has_hover_image do %>
|
||||
<div
|
||||
id={"product-image-scroll-#{@product[:id] || @product.name}"}
|
||||
id={"product-image-scroll-#{@product[:id] || @product.title}"}
|
||||
class="product-image-scroll"
|
||||
phx-hook="ProductImageScroll"
|
||||
>
|
||||
<.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"
|
||||
/>
|
||||
</div>
|
||||
<% 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}
|
||||
</a>
|
||||
<% else %>
|
||||
<.link
|
||||
@@ -172,11 +179,11 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
class="stretched-link"
|
||||
style="color: inherit; text-decoration: none;"
|
||||
>
|
||||
{@product.name}
|
||||
{@product.title}
|
||||
</.link>
|
||||
<% end %>
|
||||
<% else %>
|
||||
{@product.name}
|
||||
{@product.title}
|
||||
<% end %>
|
||||
</h3>
|
||||
<%= 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}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -256,7 +256,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
<% @source_width -> %>
|
||||
<.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 -> %>
|
||||
<img
|
||||
src={@src}
|
||||
alt={@product.name}
|
||||
alt={@alt}
|
||||
width="600"
|
||||
height="600"
|
||||
loading={if @priority, do: nil, else: "lazy"}
|
||||
@@ -307,14 +307,14 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
class="text-lg 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(@product.cheapest_price)}
|
||||
</span>
|
||||
<span class="text-sm line-through ml-2" style="color: var(--t-text-tertiary);">
|
||||
{SimpleshopTheme.Cart.format_price(@product.compare_at_price)}
|
||||
</span>
|
||||
<% else %>
|
||||
<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>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -325,15 +325,15 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
{SimpleshopTheme.Cart.format_price(@product.compare_at_price)}
|
||||
</span>
|
||||
<% end %>
|
||||
{SimpleshopTheme.Cart.format_price(@product.price)}
|
||||
{SimpleshopTheme.Cart.format_price(@product.cheapest_price)}
|
||||
</p>
|
||||
<% :compact -> %>
|
||||
<p class="font-bold" style="color: var(--t-text-primary);">
|
||||
{SimpleshopTheme.Cart.format_price(@product.price)}
|
||||
{SimpleshopTheme.Cart.format_price(@product.cheapest_price)}
|
||||
</p>
|
||||
<% :minimal -> %>
|
||||
<p class="text-xs" style="color: var(--t-text-secondary);">
|
||||
{SimpleshopTheme.Cart.format_price(@product.price)}
|
||||
{SimpleshopTheme.Cart.format_price(@product.cheapest_price)}
|
||||
</p>
|
||||
<% 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"""
|
||||
<div>
|
||||
<h1
|
||||
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);"
|
||||
>
|
||||
{@product.name}
|
||||
{@product.title}
|
||||
</h1>
|
||||
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
@@ -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)}
|
||||
</span>
|
||||
<span class="text-xl line-through" style="color: var(--t-text-tertiary);">
|
||||
{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)}%
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-3xl font-bold" style="color: var(--t-text-primary);">
|
||||
{SimpleshopTheme.Cart.format_price(@product.price)}
|
||||
{SimpleshopTheme.Cart.format_price(@price)}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user