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

@ -20,7 +20,54 @@
- Transactional emails (order confirmation, shipping notification)
- Demo content polished and ready for production
**Tier 1 MVP complete.** CI pipeline done. Hosting & deployment done (including observability). PageSpeed CI done (99-100 mobile, 97+ desktop). Next up: remaining Tier 2 items (Litestream backup, e2e tests).
**Tier 1 MVP complete.** CI pipeline done. Hosting & deployment done (including observability). PageSpeed CI done (99-100 mobile, 97+ desktop). Next up: usability fixes from user testing, then remaining Tier 2 items (Litestream backup, e2e tests).
## Usability Issues (from user testing, Feb 2025)
Issues found during hands-on testing of the deployed prod site on mobile and desktop.
**Approach:** One issue at a time, test and verify each fix before moving on.
**Principles:**
- **Semantic, minimal HTML** — achieve everything with the simplest markup possible
- **Progressive enhancement** — HTML and CSS first, then LiveView, JS only as a last resort
- **Fully accessible** — WCAG 2.1 AA compliant, proper focus management, ARIA where needed, keyboard navigable
- **Mobile-first responsive** — design for small screens first, enhance for larger viewports
- **Appropriate interactions** — touch-friendly on mobile (swipe, tap), hover/keyboard for desktop users
### Mobile / touch
- [x] Product photos require double-tap on mobile (hover state blocks first tap)
- [x] Product photos should be swipeable on mobile (hover-to-reveal is desktop-only)
- [x] Product card second image: swipe to reveal on mobile (currently hover-only)
### Product detail page
- [ ] Product category breadcrumbs look bad — review styling/layout
- [ ] Quantity selector on product page doesn't work
- [ ] Trust badges: two different tick icons, should use sentence case not title case
- [ ] Real product variants need testing and refinement with live data
### Cart
- [ ] Should be able to change quantity in the cart drawer (currently only on cart page?)
- [ ] Cart drawer button → "view basket" feels redundant — streamline the flow
### Navigation & links
- [ ] Search doesn't work (modal opens but no results/functionality)
- [ ] "Shop the collection" button/link does nothing
- [ ] Footer "New arrivals" and "Best sellers" links don't go anywhere
- [ ] Should be able to tap a category badge on product cards to go to that category
- [ ] Footer social icons should match the "Find me on" icons from the contact page
### Collections / all products
- [ ] Categories on all-products page are too spaced out
### Content pages
- [ ] Hero title spacing differs between content pages (about, delivery, etc.) — contact is fine
### Sale / filtering
- [ ] Should there be a "Sale" section or filter for discounted products?
### Errors
- [ ] 404 page is broken
## Roadmap

View File

@ -329,24 +329,87 @@
color: #ffffff;
}
/* Product Hover Image */
/* Product Card Images — mobile: swipe, desktop: hover crossfade */
.product-image-container {
position: relative;
}
.product-image-hover {
/* Mobile default: horizontal scroll-snap for swiping between images */
.product-image-scroll {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
height: 100%;
&::-webkit-scrollbar {
display: none;
}
}
.product-image-scroll > img,
.product-image-scroll > picture {
flex: 0 0 100%;
width: 100%;
height: 100%;
scroll-snap-align: start;
}
/* Dot indicators for swipeable images (mobile only) */
.product-image-dots {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 0.3s ease;
bottom: 0.5rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.375rem;
z-index: 5;
}
.product-card:hover .product-image-hover {
opacity: 1;
.product-image-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.42);
border: none;
padding: 0;
transition: all 0.2s ease;
}
.product-card:hover .product-image-primary:has(+ .product-image-hover) {
opacity: 0;
.product-image-dot-active {
width: 8px;
height: 8px;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.42);
}
/* Desktop: hover crossfade instead of scroll */
@media (hover: hover) {
.product-image-scroll {
display: contents;
}
.product-image-dots {
display: none;
}
.product-image-hover {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 0.3s ease;
}
.product-card:hover .product-image-hover {
opacity: 1;
}
.product-card:hover .product-image-primary:has(+ .product-image-hover) {
opacity: 0;
}
}
/* Social Links */

View File

@ -284,10 +284,24 @@ const Lightbox = {
}
}
const ProductImageScroll = {
mounted() {
const dots = this.el.parentElement.querySelector('.product-image-dots')
if (!dots) return
const spans = dots.querySelectorAll('.product-image-dot')
this.el.addEventListener('scroll', () => {
const index = Math.round(this.el.scrollLeft / this.el.offsetWidth)
spans.forEach((dot, i) => {
dot.classList.toggle('product-image-dot-active', i === index)
})
}, {passive: true})
}
}
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer},
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll},
})
// Show progress bar on live navigation and form submits

View File

@ -21,6 +21,7 @@ config :simpleshop_theme, :scopes,
]
config :simpleshop_theme,
env: config_env(),
ecto_repos: [SimpleshopTheme.Repo],
generators: [timestamp_type: :utc_datetime, binary_id: true]

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

View File

@ -18,12 +18,18 @@ defmodule SimpleshopThemeWeb.Endpoint do
# Serve at "/" the static files from "priv/static" directory.
# gzip is always enabled — Plug.Static serves .gz files when they exist
# (created by `mix phx.digest`), falls back to uncompressed otherwise.
# In prod, digested filenames change per deploy so immutable caching is safe.
# In dev, we omit the option to get the Plug.Static default ("public").
plug Plug.Static,
at: "/",
from: :simpleshop_theme,
gzip: true,
only: SimpleshopThemeWeb.static_paths(),
cache_control_for_etags: "public, max-age=31536000, immutable"
cache_control_for_etags:
if(Application.compile_env(:simpleshop_theme, :env) == :prod,
do: "public, max-age=31536000, immutable",
else: "public"
)
if Code.ensure_loaded?(Tidewave) do
plug Tidewave, allow_remote_access: true