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) - Transactional emails (order confirmation, shipping notification)
- Demo content polished and ready for production - 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 ## Roadmap

View File

@ -329,24 +329,87 @@
color: #ffffff; color: #ffffff;
} }
/* Product Hover Image */ /* Product Card Images — mobile: swipe, desktop: hover crossfade */
.product-image-container { .product-image-container {
position: relative; 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;
bottom: 0.5rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.375rem;
z-index: 5;
}
.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-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; position: absolute;
inset: 0; inset: 0;
opacity: 0; opacity: 0;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
} }
.product-card:hover .product-image-hover { .product-card:hover .product-image-hover {
opacity: 1; opacity: 1;
} }
.product-card:hover .product-image-primary:has(+ .product-image-hover) { .product-card:hover .product-image-primary:has(+ .product-image-hover) {
opacity: 0; opacity: 0;
}
} }
/* Social Links */ /* 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 csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, { const liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken}, 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 // Show progress bar on live navigation and form submits

View File

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

View File

@ -123,24 +123,50 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
attr :show_delivery_text, :boolean, required: true attr :show_delivery_text, :boolean, required: true
defp product_card_inner(assigns) do defp product_card_inner(assigns) do
assigns =
assign(
assigns,
:has_hover_image,
assigns.theme_settings.hover_image && assigns.product[:hover_image_url]
)
~H""" ~H"""
<div class={image_container_classes(@variant)}> <div class={image_container_classes(@variant)}>
<%= if @show_badges do %> <%= if @show_badges do %>
<.product_badge product={@product} /> <.product_badge product={@product} />
<% end %> <% end %>
<%= 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_card_image
product={@product} product={@product}
variant={@variant} variant={@variant}
priority={@priority} priority={@priority}
class="product-image-primary w-full h-full object-cover transition-opacity duration-300" class="product-image-primary w-full h-full object-cover transition-opacity duration-300"
/> />
<%= if @theme_settings.hover_image && @product[:hover_image_url] do %>
<.product_card_image <.product_card_image
product={@product} product={@product}
variant={@variant} variant={@variant}
image_key={:hover} image_key={:hover}
class="product-image-hover w-full h-full object-cover" class="product-image-hover w-full h-full object-cover"
/> />
</div>
<% else %>
<.product_card_image
product={@product}
variant={@variant}
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 %> <% end %>
</div> </div>
<div class={content_padding_class(@variant)}> <div class={content_padding_class(@variant)}>
@ -201,7 +227,30 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|> assign(:source_width, assigns.product[source_width_field]) |> assign(:source_width, assigns.product[source_width_field])
~H""" ~H"""
<%= if @source_width do %> <%= 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 <.responsive_image
src={@src} src={@src}
alt={@product.name} alt={@product.name}
@ -212,7 +261,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
height={600} height={600}
priority={@priority} priority={@priority}
/> />
<% else %> <% true -> %>
<img <img
src={@src} src={@src}
alt={@product.name} alt={@product.name}

View File

@ -18,12 +18,18 @@ defmodule SimpleshopThemeWeb.Endpoint do
# Serve at "/" the static files from "priv/static" directory. # Serve at "/" the static files from "priv/static" directory.
# gzip is always enabled — Plug.Static serves .gz files when they exist # gzip is always enabled — Plug.Static serves .gz files when they exist
# (created by `mix phx.digest`), falls back to uncompressed otherwise. # (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, plug Plug.Static,
at: "/", at: "/",
from: :simpleshop_theme, from: :simpleshop_theme,
gzip: true, gzip: true,
only: SimpleshopThemeWeb.static_paths(), 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 if Code.ensure_loaded?(Tidewave) do
plug Tidewave, allow_remote_access: true plug Tidewave, allow_remote_access: true