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:
2026-01-17 14:38:16 +00:00
parent 50941d278f
commit 9746bf183c
5 changed files with 290 additions and 174 deletions

View File

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

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">
<%= 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>

View File

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

View File

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