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>
|
||||
|
||||
Reference in New Issue
Block a user