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:
parent
50941d278f
commit
9746bf183c
@ -516,4 +516,269 @@ defmodule SimpleshopThemeWeb.ShopComponents do
|
||||
</style>
|
||||
"""
|
||||
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
|
||||
|
||||
@ -62,68 +62,13 @@
|
||||
end
|
||||
]}>
|
||||
<%= for product <- @preview_data.products do %>
|
||||
<div
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page="pdp"
|
||||
class="product-card group overflow-hidden transition-all"
|
||||
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-default); border-radius: var(--t-radius-card); cursor: pointer;"
|
||||
>
|
||||
<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>
|
||||
<.product_card
|
||||
product={product}
|
||||
theme_settings={@theme_settings}
|
||||
mode={:preview}
|
||||
variant={:default}
|
||||
show_category={true}
|
||||
/>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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">
|
||||
<%= for product <- Enum.take(@preview_data.products, 4) do %>
|
||||
<div
|
||||
class="product-card group overflow-hidden"
|
||||
style="background-color: var(--t-surface-raised); border: 1px solid var(--t-border-subtle); border-radius: var(--t-radius-card);"
|
||||
>
|
||||
<div class="product-image-container aspect-square bg-gray-200 overflow-hidden relative">
|
||||
<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>
|
||||
<.product_card
|
||||
product={product}
|
||||
theme_settings={@theme_settings}
|
||||
mode={:preview}
|
||||
variant={:minimal}
|
||||
/>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -62,53 +62,12 @@
|
||||
end
|
||||
]}>
|
||||
<%= for product <- Enum.take(@preview_data.products, 4) do %>
|
||||
<div
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page="pdp"
|
||||
class="product-card group overflow-hidden transition-all hover:-translate-y-1"
|
||||
style="background-color: var(--t-surface-raised); border-radius: var(--t-radius-card); cursor: pointer;"
|
||||
>
|
||||
<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>
|
||||
<.product_card
|
||||
product={product}
|
||||
theme_settings={@theme_settings}
|
||||
mode={:preview}
|
||||
variant={:featured}
|
||||
/>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
@ -404,42 +404,12 @@
|
||||
|
||||
<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 %>
|
||||
<a
|
||||
href="#"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page="pdp"
|
||||
class="product-card group overflow-hidden cursor-pointer"
|
||||
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>
|
||||
<.product_card
|
||||
product={related_product}
|
||||
theme_settings={@theme_settings}
|
||||
mode={:preview}
|
||||
variant={:compact}
|
||||
/>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user