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)
|
- 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
|
||||||
|
|
||||||
|
|||||||
@ -329,11 +329,73 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
.product-image-hover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@ -348,6 +410,7 @@
|
|||||||
.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 */
|
||||||
.social-link:hover {
|
.social-link:hover {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user