add mobile swipe for product card images and fix dev asset caching

Product cards now use CSS scroll-snap on touch devices (mobile) for
swiping between images, with dot indicators and a JS hook for active
state. Desktop keeps the existing hover crossfade via @media (hover:
hover). Dots use size differentiation (WCAG 2.2 AA compliant) with
outline rings for contrast on any background.

Also fixes: no-image placeholder (SVG icon instead of broken img),
unnecessary wrapper div for single-image cards, and dev static asset
caching (was immutable for all envs, now only prod).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-10 12:24:52 +00:00
parent 19b4a5bd59
commit 1a69736734
6 changed files with 222 additions and 42 deletions

View File

@@ -123,25 +123,51 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
attr :show_delivery_text, :boolean, required: true
defp product_card_inner(assigns) do
assigns =
assign(
assigns,
:has_hover_image,
assigns.theme_settings.hover_image && assigns.product[:hover_image_url]
)
~H"""
<div class={image_container_classes(@variant)}>
<%= if @show_badges do %>
<.product_badge product={@product} />
<% end %>
<.product_card_image
product={@product}
variant={@variant}
priority={@priority}
class="product-image-primary w-full h-full object-cover transition-opacity duration-300"
/>
<%= if @theme_settings.hover_image && @product[:hover_image_url] do %>
<%= if @has_hover_image do %>
<div
id={"product-image-scroll-#{@product[:id] || @product.name}"}
class="product-image-scroll"
phx-hook="ProductImageScroll"
>
<.product_card_image
product={@product}
variant={@variant}
priority={@priority}
class="product-image-primary w-full h-full object-cover transition-opacity duration-300"
/>
<.product_card_image
product={@product}
variant={@variant}
image_key={:hover}
class="product-image-hover w-full h-full object-cover"
/>
</div>
<% else %>
<.product_card_image
product={@product}
variant={@variant}
image_key={:hover}
class="product-image-hover w-full h-full object-cover"
priority={@priority}
class="product-image-primary w-full h-full object-cover"
/>
<% end %>
<%= if @has_hover_image do %>
<div class="product-image-dots" aria-hidden="true">
<span class="product-image-dot product-image-dot-active"></span>
<span class="product-image-dot"></span>
</div>
<% end %>
</div>
<div class={content_padding_class(@variant)}>
<%= if @show_category && @product[:category] do %>
@@ -201,27 +227,50 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|> assign(:source_width, assigns.product[source_width_field])
~H"""
<%= if @source_width do %>
<.responsive_image
src={@src}
alt={@product.name}
source_width={@source_width}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px"
class={@class}
width={600}
height={600}
priority={@priority}
/>
<% else %>
<img
src={@src}
alt={@product.name}
width="600"
height="600"
loading={if @priority, do: nil, else: "lazy"}
decoding={if @priority, do: nil, else: "async"}
class={@class}
/>
<%= cond do %>
<% is_nil(@src) -> %>
<div
class={[@class, "flex items-center justify-center"]}
style="color: var(--t-text-tertiary);"
role="img"
aria-label={@product.name}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-12 opacity-40"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Zm16.5-13.5a1.125 1.125 0 1 1-2.25 0 1.125 1.125 0 0 1 2.25 0Z"
/>
</svg>
</div>
<% @source_width -> %>
<.responsive_image
src={@src}
alt={@product.name}
source_width={@source_width}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px"
class={@class}
width={600}
height={600}
priority={@priority}
/>
<% true -> %>
<img
src={@src}
alt={@product.name}
width="600"
height="600"
loading={if @priority, do: nil, else: "lazy"}
decoding={if @priority, do: nil, else: "async"}
class={@class}
/>
<% end %>
"""
end