feat: product thumbnail hover/click swap — capability-driven enhancement

New /static/js/product-thumbnail-swap.js (loaded with `defer` only when
thumbnail1 is present in product.extensions): hovering a thumbnail
swaps the .product-main preview image to that thumb's source; clicking
sticks the swap and preventDefault()s the anchor so the customer stays
on the product page. Mouseleave on the .product-images container
restores the cover image unless a click has pinned it.

The no-JS path is unchanged — each thumbnail stays wrapped in an
<a href target="_blank"> so clicking still opens the full image in a
new tab when the script isn't loaded. The script self-bails on
non-img main elements (watch-mode video / audio art) and on
.audio-cover so album-art swap doesn't fire on audio products.

CSS: .product-thumbnail gains a brief border-color transition + a
:hover navy outline so the affordance reads without changing markup.

Test: asset-served check for the new JS file. The template wiring is a
single guarded line; if it regresses, the visual smoke test on the
product page catches it.
This commit is contained in:
russell@unturf.com 2026-05-15 11:40:46 -04:00
parent 873cb375a5
commit 4e203c4001
No known key found for this signature in database
4 changed files with 90 additions and 0 deletions

View file

@ -635,6 +635,13 @@ img.product-thumbnail {
max-height:58px;
width: auto;
height: auto;
transition: border-color var(--duration-fast, 100ms) var(--ease-standard, ease);
}
/* Lit-up affordance when the JS hover-swap enhancement is active —
product-thumbnail-swap.js binds hover/click handlers; the visual cue
ships in CSS so it works without changing the markup. */
.product-images img.product-thumbnail:hover {
border-color: var(--color-navy, #5871ad);
}
.product-main {

View file

@ -0,0 +1,65 @@
/* Product thumbnail swap — capability-driven enhancement.
*
* No JS: each thumbnail under .product-images is wrapped in an
* <a href="{thumb_url}" target="_blank"> so a click opens the full
* image in a new tab — that path is unchanged.
*
* With JS: hovering a thumbnail swaps the main image preview to that
* thumbnail's source; clicking sticks the swap and suppresses the
* default navigation (so the customer stays on the product page and
* keeps cycling through views, Amazon-style).
*
* Bails silently when:
* - there's no .product-images container
* - the main element isn't an <img> (watch-mode video / audio art)
* - .product-main is the audio-cover (swapping album art on hover
* would be confusing)
* - there are no .product-thumbnail children
*/
(function () {
"use strict";
var container = document.querySelector(".product-images");
if (!container) return;
var mainEl = container.querySelector("img.product-main");
if (!mainEl) return;
if (mainEl.classList.contains("audio-cover")) return;
var thumbs = container.querySelectorAll("a > img.product-thumbnail");
if (thumbs.length === 0) return;
// Remember the original src so we never lose the cover image; on
// mouseleave of the whole strip we restore it (matches expected
// "hover to preview, leave to see the cover again" behavior).
var originalSrc = mainEl.getAttribute("src");
var sticky = false; // a click pins the swap; mouseleave won't restore.
function swapTo(url) {
if (url && mainEl.getAttribute("src") !== url) {
mainEl.setAttribute("src", url);
}
}
Array.prototype.forEach.call(thumbs, function (img) {
var anchor = img.parentElement;
img.addEventListener("mouseenter", function () {
swapTo(img.src);
});
if (anchor && anchor.tagName === "A") {
anchor.addEventListener("click", function (e) {
e.preventDefault();
sticky = true;
swapTo(img.src);
});
}
});
container.addEventListener("mouseleave", function () {
if (!sticky && originalSrc) {
swapTo(originalSrc);
}
});
})();

View file

@ -132,6 +132,12 @@
<a href="{{ get_endpoint }}" target="_blank"><img src="{{ get_endpoint }}?ts={{ product.updated_timestamp }}" class="product-thumbnail" /></a>
{% endif %}
{% endfor %}
{% if "thumbnail1" in product.extensions %}
{# Capability-driven enhancement: hover/click a thumbnail to swap
the main preview. The no-JS path keeps the original
<a target="_blank"> open-in-new-tab behavior. #}
<script src="/static/js/product-thumbnail-swap.js?v={{ request.git_hash }}" defer></script>
{% endif %}
</div>
<div class="cinema-content-stack">

View file

@ -7307,6 +7307,18 @@ class TestOfferCheckout(_AuthenticatedBase):
self.assertIn("auction-page", body)
self.assertIn("auction-countdown", body)
def test_product_thumbnail_swap_js_served(self):
"""Capability-driven hover/click swap of the product cover image
against the thumbnail strip — the JS itself is asset-tested
here; the template wires it only when thumbnail1 is present."""
res = self.testapp.get(
"/static/js/product-thumbnail-swap.js", status=200,
)
body = res.body.decode()
self.assertIn(".product-images", body)
self.assertIn(".product-thumbnail", body)
self.assertIn("mouseenter", body)
def test_auction_page_loads_auction_js(self):
"""MPS-20: auction.j2 references /static/js/auction.js."""
from ..models.auction import (