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

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

View File

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

View File

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