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:
parent
19b4a5bd59
commit
1a69736734
49
PROGRESS.md
49
PROGRESS.md
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
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;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.product-card:hover .product-image-hover {
|
||||
.product-card:hover .product-image-hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.product-card:hover .product-image-primary:has(+ .product-image-hover) {
|
||||
.product-card:hover .product-image-primary:has(+ .product-image-hover) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Social Links */
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -123,24 +123,50 @@ 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 %>
|
||||
<%= 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"
|
||||
/>
|
||||
<%= if @theme_settings.hover_image && @product[:hover_image_url] do %>
|
||||
<.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}
|
||||
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)}>
|
||||
@ -201,7 +227,30 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
|> assign(:source_width, assigns.product[source_width_field])
|
||||
|
||||
~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
|
||||
src={@src}
|
||||
alt={@product.name}
|
||||
@ -212,7 +261,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
|
||||
height={600}
|
||||
priority={@priority}
|
||||
/>
|
||||
<% else %>
|
||||
<% true -> %>
|
||||
<img
|
||||
src={@src}
|
||||
alt={@product.name}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user