From 1a69736734c075dfa163f958cf458ee4f85ae866 Mon Sep 17 00:00:00 2001 From: jamey Date: Tue, 10 Feb 2026 12:24:52 +0000 Subject: [PATCH] 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 --- PROGRESS.md | 49 +++++++- assets/css/theme-layer2-attributes.css | 81 +++++++++++-- assets/js/app.js | 16 ++- config/config.exs | 1 + .../components/shop_components/product.ex | 109 +++++++++++++----- lib/simpleshop_theme_web/endpoint.ex | 8 +- 6 files changed, 222 insertions(+), 42 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index b0f6feb..5600e34 100644 --- a/PROGRESS.md +++ b/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 diff --git a/assets/css/theme-layer2-attributes.css b/assets/css/theme-layer2-attributes.css index 2cf58d4..7ea4ff9 100644 --- a/assets/css/theme-layer2-attributes.css +++ b/assets/css/theme-layer2-attributes.css @@ -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 */ diff --git a/assets/js/app.js b/assets/js/app.js index caaffeb..030ae34 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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 diff --git a/config/config.exs b/config/config.exs index 1dcb4a0..d1cbcdb 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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] diff --git a/lib/simpleshop_theme_web/components/shop_components/product.ex b/lib/simpleshop_theme_web/components/shop_components/product.ex index 1f45a8c..2b93870 100644 --- a/lib/simpleshop_theme_web/components/shop_components/product.ex +++ b/lib/simpleshop_theme_web/components/shop_components/product.ex @@ -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"""
<%= 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 %> +
+ <.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" + /> +
+ <% 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 %> + + <% end %>
<%= 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 %> - {@product.name} + <%= cond do %> + <% is_nil(@src) -> %> + + <% @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 -> %> + {@product.name} <% end %> """ end diff --git a/lib/simpleshop_theme_web/endpoint.ex b/lib/simpleshop_theme_web/endpoint.ex index fdd3484..bac0e84 100644 --- a/lib/simpleshop_theme_web/endpoint.ex +++ b/lib/simpleshop_theme_web/endpoint.ex @@ -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