refactor: extract product_card to shared ShopComponents module

Create a reusable product card component with four variants:
- :default (collection page) - full details with category
- :featured (home page) - hover animation, badges
- :compact (pdp related) - smaller, no badges/delivery
- :minimal (error 404) - smallest, not clickable

Accessibility improvements:
- Use <a> for all clickable cards (keyboard nav, screen readers)
- Use <div> only for non-clickable decorative cards

Unified badge logic with priority: Sold out > New > Sale

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jamey Greenwood 2026-01-17 14:38:16 +00:00
parent 50941d278f
commit 9746bf183c
5 changed files with 290 additions and 174 deletions

View File

@ -516,4 +516,269 @@ defmodule SimpleshopThemeWeb.ShopComponents do
</style> </style>
""" """
end end
@doc """
Renders a product card with configurable variants.
## Attributes
* `product` - Required. The product map with `name`, `image_url`, `price`, etc.
* `theme_settings` - Required. The theme settings map.
* `mode` - Either `:live` (default) or `:preview`.
* `variant` - The visual variant:
- `:default` - Collection page style with border, category, full details
- `:featured` - Home page style with hover lift, no border
- `:compact` - PDP related products with aspect-square, minimal info
- `:minimal` - Error 404 style, smallest, not clickable
* `show_category` - Show category label. Defaults based on variant.
* `show_badges` - Show product badges. Defaults based on variant.
* `show_delivery_text` - Show "Free delivery" text. Defaults based on variant.
* `clickable` - Whether the card navigates. Defaults based on variant.
## Examples
<.product_card product={product} theme_settings={@theme_settings} />
<.product_card product={product} theme_settings={@theme_settings} variant={:featured} mode={:preview} />
"""
attr :product, :map, required: true
attr :theme_settings, :map, required: true
attr :mode, :atom, default: :live
attr :variant, :atom, default: :default
attr :show_category, :boolean, default: nil
attr :show_badges, :boolean, default: nil
attr :show_delivery_text, :boolean, default: nil
attr :clickable, :boolean, default: nil
def product_card(assigns) do
# Apply variant defaults for nil values
defaults = variant_defaults(assigns.variant)
assigns =
assigns
|> assign_new(:show_category_resolved, fn ->
if assigns.show_category == nil, do: defaults.show_category, else: assigns.show_category
end)
|> assign_new(:show_badges_resolved, fn ->
if assigns.show_badges == nil, do: defaults.show_badges, else: assigns.show_badges
end)
|> assign_new(:show_delivery_text_resolved, fn ->
if assigns.show_delivery_text == nil,
do: defaults.show_delivery_text,
else: assigns.show_delivery_text
end)
|> assign_new(:clickable_resolved, fn ->
if assigns.clickable == nil, do: defaults.clickable, else: assigns.clickable
end)
~H"""
<%= if @clickable_resolved do %>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="pdp"
class={card_classes(@variant)}
style={card_style(@variant)}
>
<.product_card_inner
product={@product}
theme_settings={@theme_settings}
variant={@variant}
show_category={@show_category_resolved}
show_badges={@show_badges_resolved}
show_delivery_text={@show_delivery_text_resolved}
/>
</a>
<% else %>
<a
href={"/products/#{@product[:slug] || @product[:id]}"}
class={card_classes(@variant)}
style={card_style(@variant)}
>
<.product_card_inner
product={@product}
theme_settings={@theme_settings}
variant={@variant}
show_category={@show_category_resolved}
show_badges={@show_badges_resolved}
show_delivery_text={@show_delivery_text_resolved}
/>
</a>
<% end %>
<% else %>
<div
class={card_classes(@variant)}
style={card_style(@variant)}
>
<.product_card_inner
product={@product}
theme_settings={@theme_settings}
variant={@variant}
show_category={@show_category_resolved}
show_badges={@show_badges_resolved}
show_delivery_text={@show_delivery_text_resolved}
/>
</div>
<% end %>
"""
end
attr :product, :map, required: true
attr :theme_settings, :map, required: true
attr :variant, :atom, required: true
attr :show_category, :boolean, required: true
attr :show_badges, :boolean, required: true
attr :show_delivery_text, :boolean, required: true
defp product_card_inner(assigns) do
~H"""
<div class={image_container_classes(@variant)}>
<%= if @show_badges do %>
<.product_badge product={@product} />
<% end %>
<img
src={@product.image_url}
alt={@product.name}
loading={if @variant == :minimal, do: nil, else: "lazy"}
decoding={if @variant == :minimal, do: nil, else: "async"}
class="product-image-primary w-full h-full object-cover transition-opacity duration-300"
/>
<%= if @theme_settings.hover_image && @product[:hover_image_url] do %>
<img
src={@product.hover_image_url}
alt={@product.name}
loading={if @variant == :minimal, do: nil, else: "lazy"}
decoding={if @variant == :minimal, do: nil, else: "async"}
class="product-image-hover w-full h-full object-cover"
/>
<% end %>
</div>
<div class={content_padding_class(@variant)}>
<%= if @show_category && @product[:category] do %>
<p class="text-xs mb-1" style="color: var(--t-text-tertiary);">
<%= @product.category %>
</p>
<% end %>
<h3 class={title_classes(@variant)} style={title_style(@variant)}>
<%= @product.name %>
</h3>
<%= if @theme_settings.show_prices do %>
<.product_price product={@product} variant={@variant} />
<% end %>
<%= if @show_delivery_text do %>
<p class="text-xs mt-1" style="color: var(--t-text-tertiary);">
Free delivery over £40
</p>
<% end %>
</div>
"""
end
attr :product, :map, required: true
defp product_badge(assigns) do
~H"""
<%= cond do %>
<% Map.get(@product, :in_stock, true) == false -> %>
<span class="product-badge badge-sold-out">Sold out</span>
<% @product[:is_new] -> %>
<span class="product-badge badge-new">New</span>
<% @product[:on_sale] -> %>
<span class="product-badge badge-sale">Sale</span>
<% true -> %>
<% end %>
"""
end
attr :product, :map, required: true
attr :variant, :atom, required: true
defp product_price(assigns) do
~H"""
<%= case @variant do %>
<% :default -> %>
<div>
<%= if @product.on_sale do %>
<span class="text-lg font-bold" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));">
£<%= @product.price / 100 %>
</span>
<span class="text-sm line-through ml-2" style="color: var(--t-text-tertiary);">
£<%= @product.compare_at_price / 100 %>
</span>
<% else %>
<span class="text-lg font-bold" style="color: var(--t-text-primary);">
£<%= @product.price / 100 %>
</span>
<% end %>
</div>
<% :featured -> %>
<p class="text-sm" style="color: var(--t-text-secondary);">
<%= if @product.on_sale do %>
<span class="line-through mr-1" style="color: var(--t-text-tertiary);">£<%= @product.compare_at_price / 100 %></span>
<% end %>
£<%= @product.price / 100 %>
</p>
<% :compact -> %>
<p class="font-bold" style="color: var(--t-text-primary);">
£<%= @product.price / 100 %>
</p>
<% :minimal -> %>
<p class="text-xs" style="color: var(--t-text-secondary);">
£<%= @product.price / 100 %>
</p>
<% end %>
"""
end
defp variant_defaults(:default),
do: %{show_category: true, show_badges: true, show_delivery_text: true, clickable: true}
defp variant_defaults(:featured),
do: %{show_category: false, show_badges: true, show_delivery_text: true, clickable: true}
defp variant_defaults(:compact),
do: %{show_category: false, show_badges: false, show_delivery_text: false, clickable: true}
defp variant_defaults(:minimal),
do: %{show_category: false, show_badges: false, show_delivery_text: false, clickable: false}
defp card_classes(:default), do: "product-card group overflow-hidden transition-all"
defp card_classes(:featured), do: "product-card group overflow-hidden transition-all hover:-translate-y-1"
defp card_classes(:compact), do: "product-card group overflow-hidden cursor-pointer"
defp card_classes(:minimal), do: "product-card group overflow-hidden"
defp card_style(:default),
do: "background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card); cursor: pointer;"
defp card_style(:featured),
do: "background-color: var(--t-surface-raised); border-radius: var(--t-radius-card); cursor: pointer;"
defp card_style(:compact),
do: "background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
defp card_style(:minimal),
do: "background-color: var(--t-surface-raised); border: 1px solid var(--t-border-subtle); border-radius: var(--t-radius-card);"
defp image_container_classes(:compact),
do: "product-image-container aspect-square bg-gray-200 overflow-hidden relative"
defp image_container_classes(:minimal),
do: "product-image-container aspect-square bg-gray-200 overflow-hidden relative"
defp image_container_classes(_),
do: "product-image-container bg-gray-200 overflow-hidden relative"
defp content_padding_class(:compact), do: "p-3"
defp content_padding_class(:minimal), do: "p-2"
defp content_padding_class(_), do: ""
defp title_classes(:default), do: "font-semibold mb-2"
defp title_classes(:featured), do: "text-sm font-medium mb-1"
defp title_classes(:compact), do: "font-semibold text-sm mb-1"
defp title_classes(:minimal), do: "text-xs font-semibold truncate"
defp title_style(:default), do: "font-family: var(--t-font-heading); color: var(--t-text-primary);"
defp title_style(:featured), do: "color: var(--t-text-primary);"
defp title_style(:compact), do: "font-family: var(--t-font-heading); color: var(--t-text-primary);"
defp title_style(:minimal), do: "color: var(--t-text-primary);"
end end

View File

@ -62,68 +62,13 @@
end end
]}> ]}>
<%= for product <- @preview_data.products do %> <%= for product <- @preview_data.products do %>
<div <.product_card
phx-click="change_preview_page" product={product}
phx-value-page="pdp" theme_settings={@theme_settings}
class="product-card group overflow-hidden transition-all" mode={:preview}
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card); cursor: pointer;" variant={:default}
> show_category={true}
<div class="product-image-container bg-gray-200 overflow-hidden relative">
<!-- Product Badge -->
<%= cond do %>
<% not product.in_stock -> %>
<span class="product-badge badge-sold-out">Sold out</span>
<% product.on_sale -> %>
<span class="product-badge badge-sale">Sale</span>
<% true -> %>
<% end %>
<!-- Primary Image -->
<img
src={product.image_url}
alt={product.name}
loading="lazy"
decoding="async"
class="product-image-primary w-full h-full object-cover transition-opacity duration-300"
/> />
<!-- Hover Image -->
<%= if @theme_settings.hover_image && product[:hover_image_url] do %>
<img
src={product.hover_image_url}
alt={product.name}
loading="lazy"
decoding="async"
class="product-image-hover w-full h-full object-cover"
/>
<% end %>
</div>
<div>
<p class="text-xs mb-1" style="color: var(--t-text-tertiary);">
<%= product.category %>
</p>
<h3 class="font-semibold mb-2" style="font-family: var(--t-font-heading); color: var(--t-text-primary);">
<%= product.name %>
</h3>
<%= if @theme_settings.show_prices do %>
<div>
<%= if product.on_sale do %>
<span class="text-lg font-bold" style="color: hsl(var(--t-accent-h) var(--t-accent-s) var(--t-accent-l));">
£<%= product.price / 100 %>
</span>
<span class="text-sm line-through ml-2" style="color: var(--t-text-tertiary);">
£<%= product.compare_at_price / 100 %>
</span>
<% else %>
<span class="text-lg font-bold" style="color: var(--t-text-primary);">
£<%= product.price / 100 %>
</span>
<% end %>
</div>
<% end %>
<p class="text-xs mt-1" style="color: var(--t-text-tertiary);">
Free delivery over £40
</p>
</div>
</div>
<% end %> <% end %>
</div> </div>
</div> </div>

View File

@ -42,35 +42,12 @@
<div class="product-grid mt-12 grid gap-4 grid-cols-2 md:grid-cols-4 max-w-xl mx-auto"> <div class="product-grid mt-12 grid gap-4 grid-cols-2 md:grid-cols-4 max-w-xl mx-auto">
<%= for product <- Enum.take(@preview_data.products, 4) do %> <%= for product <- Enum.take(@preview_data.products, 4) do %>
<div <.product_card
class="product-card group overflow-hidden" product={product}
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-subtle); border-radius: var(--t-radius-card);" theme_settings={@theme_settings}
> mode={:preview}
<div class="product-image-container aspect-square bg-gray-200 overflow-hidden relative"> variant={:minimal}
<img
src={product.image_url}
alt={product.name}
class="product-image-primary w-full h-full object-cover transition-opacity duration-300"
/> />
<%= if @theme_settings.hover_image && product[:hover_image_url] do %>
<img
src={product.hover_image_url}
alt={product.name}
class="product-image-hover w-full h-full object-cover"
/>
<% end %>
</div>
<div class="p-2">
<p class="text-xs font-semibold truncate" style="color: var(--t-text-primary);">
<%= product.name %>
</p>
<%= if @theme_settings.show_prices do %>
<p class="text-xs" style="color: var(--t-text-secondary);">
£<%= product.price / 100 %>
</p>
<% end %>
</div>
</div>
<% end %> <% end %>
</div> </div>
</div> </div>

View File

@ -62,53 +62,12 @@
end end
]}> ]}>
<%= for product <- Enum.take(@preview_data.products, 4) do %> <%= for product <- Enum.take(@preview_data.products, 4) do %>
<div <.product_card
phx-click="change_preview_page" product={product}
phx-value-page="pdp" theme_settings={@theme_settings}
class="product-card group overflow-hidden transition-all hover:-translate-y-1" mode={:preview}
style="background-color: var(--t-surface-raised); border-radius: var(--t-radius-card); cursor: pointer;" variant={:featured}
>
<div class="product-image-container bg-gray-200 overflow-hidden relative">
<%= if product[:is_new] do %>
<span class="product-badge badge-new">New</span>
<% end %>
<%= if product.on_sale do %>
<span class="product-badge badge-sale">Sale</span>
<% end %>
<img
src={product.image_url}
alt={product.name}
loading="lazy"
decoding="async"
class="product-image-primary w-full h-full object-cover transition-opacity duration-300"
/> />
<%= if @theme_settings.hover_image && product[:hover_image_url] do %>
<img
src={product.hover_image_url}
alt={product.name}
loading="lazy"
decoding="async"
class="product-image-hover w-full h-full object-cover"
/>
<% end %>
</div>
<div>
<h3 class="text-sm font-medium mb-1" style="color: var(--t-text-primary);">
<%= product.name %>
</h3>
<%= if @theme_settings.show_prices do %>
<p class="text-sm" style="color: var(--t-text-secondary);">
<%= if product.on_sale do %>
<span class="line-through mr-1" style="color: var(--t-text-tertiary);">£<%= product.compare_at_price / 100 %></span>
<% end %>
£<%= product.price / 100 %>
</p>
<% end %>
<p class="text-xs mt-1" style="color: var(--t-text-tertiary);">
Free delivery over £40
</p>
</div>
</div>
<% end %> <% end %>
</div> </div>

View File

@ -404,42 +404,12 @@
<div class="product-grid grid gap-6 grid-cols-2 md:grid-cols-4"> <div class="product-grid grid gap-6 grid-cols-2 md:grid-cols-4">
<%= for related_product <- Enum.slice(@preview_data.products, 1, 4) do %> <%= for related_product <- Enum.slice(@preview_data.products, 1, 4) do %>
<a <.product_card
href="#" product={related_product}
phx-click="change_preview_page" theme_settings={@theme_settings}
phx-value-page="pdp" mode={:preview}
class="product-card group overflow-hidden cursor-pointer" variant={:compact}
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card);"
>
<div class="product-image-container aspect-square bg-gray-200 overflow-hidden relative">
<img
src={related_product.image_url}
alt={related_product.name}
loading="lazy"
decoding="async"
class="product-image-primary w-full h-full object-cover transition-opacity duration-300"
/> />
<%= if @theme_settings.hover_image && related_product[:hover_image_url] do %>
<img
src={related_product.hover_image_url}
alt={related_product.name}
loading="lazy"
decoding="async"
class="product-image-hover w-full h-full object-cover"
/>
<% end %>
</div>
<div class="p-3">
<h3 class="font-semibold text-sm mb-1" style="font-family: var(--t-font-heading); color: var(--t-text-primary);">
<%= related_product.name %>
</h3>
<%= if @theme_settings.show_prices do %>
<p class="font-bold" style="color: var(--t-text-primary);">
£<%= related_product.price / 100 %>
</p>
<% end %>
</div>
</a>
<% end %> <% end %>
</div> </div>
</div> </div>